gg
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
LuBadgeAlert,
|
||||
LuCheck,
|
||||
LuCircleHelp,
|
||||
LuDatabase,
|
||||
LuEye,
|
||||
LuEyeOff,
|
||||
LuLayers3,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { SlideUp } from "@/components/motion";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import { Tooltip } from "@/components/ui/overlays/tooltip";
|
||||
import FrequencyPanel from "@/components/coupons/frequency-panel";
|
||||
import { useSuggestCoupon } from "@/lib/api/coupons/use-hooks";
|
||||
import type {
|
||||
CouponItemDto,
|
||||
@@ -352,6 +354,7 @@ export default function CouponBuilderContent() {
|
||||
SmartCouponResultDto | undefined
|
||||
>(undefined);
|
||||
const [matchCount, setMatchCount] = React.useState<number>(5); // Default: 5 matches
|
||||
const [engineMode, setEngineMode] = React.useState<"ai" | "frequency">("ai");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!upcomingQuery.data && !upcomingQuery.isPending) {
|
||||
@@ -763,6 +766,42 @@ export default function CouponBuilderContent() {
|
||||
</HStack>
|
||||
</Card.Header>
|
||||
<Card.Body pt={0}>
|
||||
{/* Engine Mode Toggle */}
|
||||
<VStack align="stretch" gap={2} mb={4}>
|
||||
<HStack gap={2}>
|
||||
<Icon as={engineMode === "ai" ? LuSparkles : LuDatabase} color={engineMode === "ai" ? "teal.500" : "cyan.500"} />
|
||||
<Text fontWeight="semibold" fontSize="sm">{t("engine-mode-label")}</Text>
|
||||
<InfoIcon content={t("engine-mode-help")} label={t("engine-mode-label")} />
|
||||
</HStack>
|
||||
<HStack gap={2}>
|
||||
<Badge
|
||||
colorPalette={engineMode === "ai" ? "teal" : "gray"}
|
||||
variant={engineMode === "ai" ? "solid" : "outline"}
|
||||
cursor="pointer" px={3} py={1}
|
||||
onClick={() => setEngineMode("ai")}
|
||||
>
|
||||
<LuSparkles /> AI
|
||||
</Badge>
|
||||
<Badge
|
||||
colorPalette={engineMode === "frequency" ? "cyan" : "gray"}
|
||||
variant={engineMode === "frequency" ? "solid" : "outline"}
|
||||
cursor="pointer" px={3} py={1}
|
||||
onClick={() => setEngineMode("frequency")}
|
||||
>
|
||||
<LuDatabase /> Frekans
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={engineMode === "ai" ? "teal.500" : "cyan.500"}>
|
||||
{engineMode === "ai" ? t("ai-mode-active") : t("freq-mode-active")}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Separator mb={4} />
|
||||
|
||||
{engineMode === "frequency" ? (
|
||||
<FrequencyPanel />
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
@@ -920,6 +959,8 @@ export default function CouponBuilderContent() {
|
||||
? t("manual-selection-helper")
|
||||
: t("automatic-selection-helper")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"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,
|
||||
LuBarChart3,
|
||||
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={LuBarChart3} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user