This commit is contained in:
2026-04-22 02:17:12 +03:00
parent 538612c8ea
commit 4896323e04
7 changed files with 621 additions and 2 deletions
@@ -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>
+451
View File
@@ -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>
);
}
+13
View File
@@ -10,6 +10,8 @@ import {
MatchAnalysisResultDto,
DailyBankoResponseDto,
SmartCouponResultDto,
FrequencyCouponRequestDto,
FrequencyCouponResultDto,
} from "./types";
/**
@@ -70,6 +72,15 @@ const suggestCoupon = (dto: SuggestCouponDto) => {
});
};
const generateFrequencyCoupon = (dto: FrequencyCouponRequestDto) => {
return apiRequest<ApiResponse<FrequencyCouponResultDto>>({
url: "/coupon/frequency-coupon",
client: "core",
method: "post",
data: dto,
});
};
export const couponsService = {
analyzeMatch,
createCoupon,
@@ -77,4 +88,6 @@ export const couponsService = {
getHistory,
getUserStats,
suggestCoupon,
generateFrequencyCoupon,
};
+47
View File
@@ -106,3 +106,50 @@ export interface SmartCouponResultDto {
expected_win_rate: number;
rejected_matches: SuggestedCouponRejectedMatchDto[];
}
// ========================
// Frequency Engine DTOs
// ========================
export interface FrequencyCouponRequestDto {
matchIds?: string[];
maxMatches?: number;
minSignal?: number;
markets?: string[];
}
export interface FrequencyCouponBetDto {
match_id: string;
match_name: string;
league: string;
market: string;
pick: string;
home_signal: number;
away_signal: number;
combined_signal: number;
league_profile: string;
historical_hit_rate: number;
odds: number;
home_odds_band: string;
away_odds_band: string;
home_match_count: number;
away_match_count: number;
}
export interface FrequencyCouponRejectedDto {
match_id: string;
match_name: string;
reason: string;
}
export interface FrequencyCouponResultDto {
strategy: "FREQUENCY";
generated_at: string;
bets: FrequencyCouponBetDto[];
total_odds: number;
expected_hit_rate: number;
expected_value: number;
ev_positive: boolean;
reasoning: string[];
rejected_matches: FrequencyCouponRejectedDto[];
}
+9
View File
@@ -4,6 +4,7 @@ import type {
CreateCouponDto,
SuggestCouponDto,
AnalyzeMatchDto,
FrequencyCouponRequestDto,
} from "./types";
export const CouponsQueryKeys = {
@@ -55,3 +56,11 @@ export const useSuggestCoupon = () => {
mutationFn: (dto: SuggestCouponDto) => couponsService.suggestCoupon(dto),
});
};
export const useGenerateFrequencyCoupon = () => {
return useMutation({
mutationFn: (dto: FrequencyCouponRequestDto) =>
couponsService.generateFrequencyCoupon(dto),
});
};