452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Flex,
|
|
Grid,
|
|
Heading,
|
|
HStack,
|
|
Icon,
|
|
IconButton,
|
|
Separator,
|
|
Text,
|
|
VStack,
|
|
} from "@chakra-ui/react";
|
|
import { useTranslations } from "next-intl";
|
|
import React from "react";
|
|
import {
|
|
LuBadgeAlert,
|
|
LuChartBar,
|
|
LuCircleHelp,
|
|
LuDatabase,
|
|
LuTrendingUp,
|
|
LuZap,
|
|
} from "react-icons/lu";
|
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
|
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
|
import { useGenerateFrequencyCoupon } from "@/lib/api/coupons/use-hooks";
|
|
import type {
|
|
FrequencyCouponResultDto,
|
|
FrequencyCouponBetDto,
|
|
} from "@/lib/api/coupons/types";
|
|
import { ApiError } from "@/lib/api/create-api-client";
|
|
import { useCouponStore } from "@/lib/stores/coupon-store";
|
|
|
|
const AVAILABLE_MARKETS = ["OU1.5", "OU2.5", "OU3.5", "BTTS", "MS"];
|
|
|
|
function InfoIcon({ content, label }: { content: string; label: string }) {
|
|
return (
|
|
<Tooltip
|
|
content={content}
|
|
showArrow
|
|
positioning={{ placement: "top" }}
|
|
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
|
|
>
|
|
<IconButton
|
|
aria-label={label}
|
|
variant="ghost"
|
|
size="2xs"
|
|
colorPalette="gray"
|
|
>
|
|
<LuCircleHelp />
|
|
</IconButton>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const profileColor = (p: string) =>
|
|
({ GOLCU: "red", DEFANSIF: "blue", NORMAL: "gray" })[p] || "gray";
|
|
|
|
const profileLabel = (p: string, t: ReturnType<typeof useTranslations>) =>
|
|
({
|
|
GOLCU: t("freq-league-golcu"),
|
|
DEFANSIF: t("freq-league-defansif"),
|
|
NORMAL: t("freq-league-normal"),
|
|
})[p] || p;
|
|
|
|
export default function FrequencyPanel() {
|
|
const t = useTranslations("coupons");
|
|
const { addItem, clearCoupon } = useCouponStore();
|
|
|
|
const cardBg = useColorModeValue("white", "gray.800");
|
|
const mutedBg = useColorModeValue("gray.50", "whiteAlpha.50");
|
|
const borderColor = useColorModeValue("gray.200", "gray.700");
|
|
|
|
const freqMutation = useGenerateFrequencyCoupon();
|
|
|
|
const [minSignal, setMinSignal] = React.useState(0.65);
|
|
const [maxMatches, setMaxMatches] = React.useState(3);
|
|
const [selectedMarkets, setSelectedMarkets] = React.useState<string[]>([]);
|
|
const [result, setResult] = React.useState<
|
|
FrequencyCouponResultDto | undefined
|
|
>(undefined);
|
|
|
|
const toggleMarket = (m: string) =>
|
|
setSelectedMarkets((prev) =>
|
|
prev.includes(m) ? prev.filter((x) => x !== m) : [...prev, m],
|
|
);
|
|
|
|
const handleGenerate = () => {
|
|
freqMutation.mutate(
|
|
{
|
|
maxMatches,
|
|
minSignal,
|
|
markets: selectedMarkets.length > 0 ? selectedMarkets : undefined,
|
|
},
|
|
{
|
|
onSuccess: (response) => {
|
|
const data = (response as any)?.data ?? response;
|
|
setResult(data as FrequencyCouponResultDto);
|
|
// Sync to coupon store
|
|
if (data && Array.isArray((data as any).bets)) {
|
|
clearCoupon();
|
|
(data as FrequencyCouponResultDto).bets.forEach(
|
|
(bet: FrequencyCouponBetDto) =>
|
|
addItem({
|
|
matchId: bet.match_id,
|
|
matchName: bet.match_name,
|
|
market: bet.market,
|
|
pick: bet.pick,
|
|
odd: bet.odds,
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
onError: () => setResult(undefined),
|
|
},
|
|
);
|
|
};
|
|
|
|
const errorMessage =
|
|
freqMutation.error instanceof ApiError
|
|
? freqMutation.error.message
|
|
: freqMutation.error instanceof Error
|
|
? freqMutation.error.message
|
|
: undefined;
|
|
|
|
return (
|
|
<VStack gap={4} align="stretch">
|
|
{/* Controls Card */}
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
|
<Card.Header>
|
|
<HStack gap={2}>
|
|
<Icon as={LuDatabase} color="cyan.500" boxSize={4.5} />
|
|
<Heading as="h3" size="sm">
|
|
{t("freq-engine-title")}
|
|
</Heading>
|
|
<InfoIcon
|
|
label={t("freq-engine-title")}
|
|
content={t("freq-engine-subtitle")}
|
|
/>
|
|
</HStack>
|
|
<Text fontSize="sm" color="fg.muted" mt={1}>
|
|
{t("freq-engine-subtitle")}
|
|
</Text>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
{/* Min Signal Slider */}
|
|
<VStack align="stretch" gap={2} mb={4}>
|
|
<HStack justify="space-between">
|
|
<HStack gap={2}>
|
|
<Icon as={LuTrendingUp} color="cyan.500" />
|
|
<Text fontWeight="semibold" fontSize="sm">
|
|
{t("freq-min-signal")}
|
|
</Text>
|
|
<InfoIcon
|
|
content={t("freq-min-signal-help")}
|
|
label={t("freq-min-signal")}
|
|
/>
|
|
</HStack>
|
|
<Badge colorPalette="cyan" variant="subtle">
|
|
{(minSignal * 100).toFixed(0)}%
|
|
</Badge>
|
|
</HStack>
|
|
<input
|
|
type="range"
|
|
min="50"
|
|
max="95"
|
|
value={minSignal * 100}
|
|
onChange={(e) => setMinSignal(Number(e.target.value) / 100)}
|
|
style={{ width: "100%", accentColor: "#0891b2", cursor: "pointer" }}
|
|
/>
|
|
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
|
<Text>50%</Text>
|
|
<Text>95%</Text>
|
|
</HStack>
|
|
</VStack>
|
|
|
|
{/* Max Matches */}
|
|
<VStack align="stretch" gap={2} mb={4}>
|
|
<HStack justify="space-between">
|
|
<HStack gap={2}>
|
|
<Icon as={LuChartBar} color="purple.500" />
|
|
<Text fontWeight="semibold" fontSize="sm">
|
|
{t("match-count-label")}
|
|
</Text>
|
|
</HStack>
|
|
<Badge colorPalette="purple" variant="subtle">
|
|
{maxMatches}
|
|
</Badge>
|
|
</HStack>
|
|
<input
|
|
type="range"
|
|
min="2"
|
|
max="5"
|
|
value={maxMatches}
|
|
onChange={(e) => setMaxMatches(Number(e.target.value))}
|
|
style={{ width: "100%", accentColor: "#9333ea", cursor: "pointer" }}
|
|
/>
|
|
<HStack justify="space-between" fontSize="xs" color="fg.muted">
|
|
<Text>2</Text>
|
|
<Text>5</Text>
|
|
</HStack>
|
|
</VStack>
|
|
|
|
<Separator mb={4} />
|
|
|
|
{/* Market Filter */}
|
|
<VStack align="stretch" gap={2} mb={4}>
|
|
<HStack gap={2}>
|
|
<Text fontWeight="semibold" fontSize="sm">
|
|
{t("freq-markets")}
|
|
</Text>
|
|
<InfoIcon
|
|
content={t("freq-markets-help")}
|
|
label={t("freq-markets")}
|
|
/>
|
|
</HStack>
|
|
<HStack gap={2} flexWrap="wrap">
|
|
{AVAILABLE_MARKETS.map((m) => {
|
|
const active = selectedMarkets.includes(m);
|
|
return (
|
|
<Badge
|
|
key={m}
|
|
colorPalette={active ? "cyan" : "gray"}
|
|
variant={active ? "solid" : "outline"}
|
|
cursor="pointer"
|
|
px={3}
|
|
py={1}
|
|
onClick={() => toggleMarket(m)}
|
|
_hover={{ opacity: 0.8 }}
|
|
>
|
|
{m}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</HStack>
|
|
{selectedMarkets.length === 0 && (
|
|
<Text fontSize="xs" color="fg.muted">
|
|
Tüm marketler taranacak
|
|
</Text>
|
|
)}
|
|
</VStack>
|
|
|
|
<Button
|
|
variant="solid"
|
|
colorPalette="cyan"
|
|
size="lg"
|
|
width="full"
|
|
borderRadius="xl"
|
|
loading={freqMutation.isPending}
|
|
loadingText={t("freq-suggest-loading")}
|
|
onClick={handleGenerate}
|
|
>
|
|
<LuZap />
|
|
{t("freq-suggest")}
|
|
</Button>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
{/* Results Card */}
|
|
{result && result.bets.length > 0 && (
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
|
<Card.Header>
|
|
<HStack justify="space-between" align="center">
|
|
<HStack gap={2}>
|
|
<Icon as={LuZap} color="cyan.500" boxSize={4.5} />
|
|
<Heading as="h3" size="sm">
|
|
{t("freq-engine-title")}
|
|
</Heading>
|
|
</HStack>
|
|
<Badge
|
|
colorPalette={result.ev_positive ? "green" : "red"}
|
|
variant="solid"
|
|
>
|
|
{result.ev_positive
|
|
? t("freq-ev-positive")
|
|
: t("freq-ev-negative")}
|
|
</Badge>
|
|
</HStack>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
<VStack align="stretch" gap={3}>
|
|
{/* EV Stats */}
|
|
<Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={3}>
|
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
|
{t("freq-ev-label")}
|
|
</Text>
|
|
<Text
|
|
fontWeight="bold"
|
|
fontSize="lg"
|
|
color={result.ev_positive ? "green.500" : "red.500"}
|
|
>
|
|
{result.expected_value.toFixed(3)}
|
|
</Text>
|
|
</Box>
|
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
|
{t("freq-hit-rate")}
|
|
</Text>
|
|
<Text fontWeight="bold" fontSize="lg" color="cyan.500">
|
|
{(result.expected_hit_rate * 100).toFixed(1)}%
|
|
</Text>
|
|
</Box>
|
|
<Box p={3} bg={mutedBg} borderRadius="xl">
|
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
|
{t("total-odds")}
|
|
</Text>
|
|
<Text fontWeight="bold" fontSize="lg" color="purple.500">
|
|
{result.total_odds.toFixed(2)}
|
|
</Text>
|
|
</Box>
|
|
</Grid>
|
|
|
|
{/* Bets */}
|
|
{result.bets.map((bet: FrequencyCouponBetDto) => (
|
|
<Box
|
|
key={`${bet.match_id}-${bet.market}`}
|
|
p={4}
|
|
borderWidth="1px"
|
|
borderColor={borderColor}
|
|
borderRadius="xl"
|
|
bg={mutedBg}
|
|
>
|
|
<Flex justify="space-between" align="flex-start" gap={3} mb={3}>
|
|
<VStack align="flex-start" gap={1}>
|
|
<Text fontWeight="bold">{bet.match_name}</Text>
|
|
<Text fontSize="sm" color="fg.muted">
|
|
{bet.league} • {bet.market}: {bet.pick}
|
|
</Text>
|
|
</VStack>
|
|
<Badge colorPalette="cyan" variant="solid">
|
|
{bet.odds.toFixed(2)}
|
|
</Badge>
|
|
</Flex>
|
|
<Grid templateColumns="repeat(3, minmax(0, 1fr))" gap={2}>
|
|
<Box>
|
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
|
{t("freq-home-signal")}
|
|
</Text>
|
|
<Text fontSize="sm" fontWeight="semibold">
|
|
{(bet.home_signal * 100).toFixed(0)}%
|
|
</Text>
|
|
<Text fontSize="xs" color="fg.muted">
|
|
{bet.home_odds_band}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
|
{t("freq-away-signal")}
|
|
</Text>
|
|
<Text fontSize="sm" fontWeight="semibold">
|
|
{(bet.away_signal * 100).toFixed(0)}%
|
|
</Text>
|
|
<Text fontSize="xs" color="fg.muted">
|
|
{bet.away_odds_band}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text fontSize="xs" color="fg.muted" mb={1}>
|
|
{t("freq-combined-signal")}
|
|
</Text>
|
|
<Text fontSize="sm" fontWeight="bold" color="cyan.500">
|
|
{(bet.combined_signal * 100).toFixed(0)}%
|
|
</Text>
|
|
</Box>
|
|
</Grid>
|
|
<HStack gap={2} mt={3} flexWrap="wrap">
|
|
<Badge
|
|
colorPalette={profileColor(bet.league_profile)}
|
|
variant="subtle"
|
|
>
|
|
{t("freq-league-profile")}:{" "}
|
|
{profileLabel(bet.league_profile, t)}
|
|
</Badge>
|
|
<Badge colorPalette="gray" variant="subtle">
|
|
{t("freq-match-count")}: {bet.home_match_count}/
|
|
{bet.away_match_count}
|
|
</Badge>
|
|
</HStack>
|
|
</Box>
|
|
))}
|
|
|
|
{/* Reasoning */}
|
|
{result.reasoning.length > 0 && (
|
|
<Box p={4} bg={mutedBg} borderRadius="xl">
|
|
<Text fontWeight="semibold" fontSize="sm" mb={2}>
|
|
{t("freq-reasoning-title")}
|
|
</Text>
|
|
<VStack align="stretch" gap={1}>
|
|
{result.reasoning.map((r, i) => (
|
|
<Text key={i} fontSize="xs" color="fg.muted">
|
|
• {r}
|
|
</Text>
|
|
))}
|
|
</VStack>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Rejected */}
|
|
{result.rejected_matches.length > 0 && (
|
|
<Box p={4} bg="orange.50" borderRadius="xl">
|
|
<HStack gap={2} mb={2}>
|
|
<Icon as={LuBadgeAlert} color="orange.500" />
|
|
<Text fontWeight="semibold">{t("rejected-matches-title")}</Text>
|
|
</HStack>
|
|
<VStack align="stretch" gap={1}>
|
|
{result.rejected_matches.map((entry, i) => (
|
|
<Text key={i} fontSize="sm" color="fg.muted">
|
|
{entry.match_name}: {entry.reason}
|
|
</Text>
|
|
))}
|
|
</VStack>
|
|
</Box>
|
|
)}
|
|
</VStack>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
)}
|
|
|
|
{/* No result message */}
|
|
{result && result.bets.length === 0 && (
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
|
<Card.Body py={8}>
|
|
<Text textAlign="center" color="fg.muted">
|
|
{t("freq-no-result")}
|
|
</Text>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{errorMessage && (
|
|
<Box
|
|
p={4}
|
|
bg="red.50"
|
|
borderRadius="xl"
|
|
borderWidth="1px"
|
|
borderColor="red.200"
|
|
>
|
|
<Text fontSize="sm" color="red.700">
|
|
{errorMessage}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</VStack>
|
|
);
|
|
}
|