Files
iddaai-fe/src/components/coupons/coupon-builder-content.tsx
T
2026-04-22 02:17:12 +03:00

1201 lines
44 KiB
TypeScript

"use client";
import {
Badge,
Box,
Button,
Card,
Flex,
Grid,
Heading,
HStack,
Icon,
IconButton,
Separator,
SimpleGrid,
Spinner,
Text,
VStack,
} from "@chakra-ui/react";
import { useLocale, useMessages, useTranslations } from "next-intl";
import React from "react";
import {
LuBadgeAlert,
LuCheck,
LuCircleHelp,
LuDatabase,
LuEye,
LuEyeOff,
LuLayers3,
LuListChecks,
LuLock,
LuRefreshCcw,
LuShieldCheck,
LuSparkles,
LuTarget,
LuTrash,
LuTrophy,
} from "react-icons/lu";
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,
SmartCouponResultDto,
SuggestedCouponBetDto,
} from "@/lib/api/coupons/types";
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
import type {
LeagueWithMatchesDto,
MatchResponseDto,
} from "@/lib/api/matches/types";
import type { CouponStrategy } from "@/lib/api/predictions/types";
import { useCouponStore } from "@/lib/stores/coupon-store";
import { ApiError } from "@/lib/api/create-api-client";
const formatDate = (ts: number | string, locale: string) =>
new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(typeof ts === "string" ? Number(ts) : ts));
const formatPercent = (v?: number, d = 0) =>
v === undefined || Number.isNaN(v) ? "-" : `${v.toFixed(d)}%`;
const formatOdds = (v?: number) =>
v === undefined || Number.isNaN(v) ? "-" : v.toFixed(2);
const strategyPalette = (s: CouponStrategy) =>
({
SAFE: "green",
BALANCED: "blue",
AGGRESSIVE: "orange",
VALUE: "red",
MIRACLE: "purple",
})[s] || "gray";
const riskPalette = (v?: string) =>
({ LOW: "green", MEDIUM: "yellow", HIGH: "orange", EXTREME: "red" })[
(v || "").toUpperCase()
] || "gray";
const qualityPalette = (v?: string) =>
({ HIGH: "green", MEDIUM: "yellow", LOW: "red" })[(v || "").toUpperCase()] ||
"gray";
function normalizeSmartCouponResult(
value: unknown,
): SmartCouponResultDto | undefined {
if (!value || typeof value !== "object") return undefined;
let payload = value as Record<string, unknown>;
while (
payload &&
typeof payload === "object" &&
!Array.isArray(payload.bets) &&
payload.data &&
typeof payload.data === "object"
) {
payload = payload.data as Record<string, unknown>;
}
if (!Array.isArray(payload.bets)) return undefined;
return {
strategy: String(payload.strategy || "BALANCED") as CouponStrategy,
generated_at: String(payload.generated_at || ""),
match_count: Number(payload.match_count || 0),
bets: payload.bets as SuggestedCouponBetDto[],
total_odds: Number(payload.total_odds || 0),
expected_win_rate: Number(payload.expected_win_rate || 0),
rejected_matches: Array.isArray(payload.rejected_matches)
? (payload.rejected_matches as SmartCouponResultDto["rejected_matches"])
: [],
};
}
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>
);
}
function StatCard({
label,
value,
helper,
accent,
}: {
label: string;
value: string;
helper?: string;
accent?: string;
}) {
const bg = useColorModeValue("white", "gray.900");
const borderColor = useColorModeValue("gray.200", "gray.700");
return (
<Box
p={4}
bg={bg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
>
<HStack justify="space-between" mb={2}>
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
{label}
</Text>
{helper ? <InfoIcon content={helper} label={label} /> : null}
</HStack>
<Text fontSize="2xl" fontWeight="bold" color={accent}>
{value}
</Text>
</Box>
);
}
function MatchGroups({
groups,
selectable,
selectedIds,
locale,
onToggle,
badgeLabel,
secondaryBadge,
readOnlyLabel,
matchStateLabel,
finishedReferenceLabel,
t,
tCommon,
}: {
groups: LeagueWithMatchesDto[];
selectable: boolean;
selectedIds: string[];
locale: string;
onToggle: (id: string) => void;
badgeLabel: string;
secondaryBadge?: string;
readOnlyLabel: string;
matchStateLabel: string;
finishedReferenceLabel: string;
t: ReturnType<typeof useTranslations>;
tCommon: ReturnType<typeof useTranslations>;
}) {
const cardBg = useColorModeValue("white", "gray.800");
const mutedBg = useColorModeValue("gray.50", "whiteAlpha.50");
const borderColor = useColorModeValue("gray.200", "gray.700");
const selectedBorder = useColorModeValue("teal.400", "teal.300");
const subtleGreenBg = useColorModeValue("green.50", "green.950");
return (
<VStack gap={4} align="stretch">
{groups.map((league) => (
<Card.Root
key={`${badgeLabel}-${league.id}`}
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Header pb={2}>
<VStack align="flex-start" gap={1}>
<Text fontWeight="semibold">{league.name}</Text>
<Text fontSize="sm" color="fg.muted">
{league.country?.name} {league.matches.length}{" "}
{t("match-count-suffix")}
</Text>
</VStack>
</Card.Header>
<Card.Body pt={0}>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
{league.matches.map((match) => {
const selected = selectedIds.includes(match.id);
return (
<Box
key={match.id}
p={4}
bg={selectable && selected ? subtleGreenBg : mutedBg}
borderWidth="1px"
borderColor={
selectable && selected ? selectedBorder : borderColor
}
borderRadius="xl"
cursor={selectable ? "pointer" : "default"}
_hover={
selectable
? {
borderColor: selectedBorder,
transform: "translateY(-1px)",
}
: undefined
}
onClick={selectable ? () => onToggle(match.id) : undefined}
>
<Flex
justify="space-between"
align="flex-start"
gap={3}
mb={3}
>
<VStack align="flex-start" gap={1}>
<HStack gap={2} flexWrap="wrap">
<Badge
colorPalette={selectable ? "teal" : "gray"}
variant="subtle"
>
{badgeLabel}
</Badge>
{selectable && selected ? (
<Badge colorPalette="green" variant="solid">
{t("selected-short")}
</Badge>
) : null}
{!selectable && secondaryBadge ? (
<Badge colorPalette="orange" variant="subtle">
{secondaryBadge}
</Badge>
) : null}
</HStack>
<Text fontWeight="bold">{match.matchName}</Text>
</VStack>
{selectable ? (
<Button
variant={selected ? "solid" : "outline"}
colorPalette={selected ? "green" : "teal"}
size="xs"
onClick={(e) => {
e.stopPropagation();
onToggle(match.id);
}}
>
{selected ? t("selected-short") : t("select-match")}
</Button>
) : (
<HStack gap={1} color="fg.muted">
<LuLock />
<Text fontSize="xs">{readOnlyLabel}</Text>
</HStack>
)}
</Flex>
<Grid templateColumns="repeat(2, minmax(0, 1fr))" gap={3}>
<Box>
<Text fontSize="xs" color="fg.muted" mb={1}>
{tCommon("date")}
</Text>
<Text fontSize="sm" fontWeight="medium">
{formatDate(match.mstUtc, locale)}
</Text>
</Box>
<Box>
<Text fontSize="xs" color="fg.muted" mb={1}>
{selectable ? t("selection-mode") : matchStateLabel}
</Text>
<Text fontSize="sm" fontWeight="medium">
{selectable
? selected
? t("manual-pool")
: t("auto-pool")
: finishedReferenceLabel}
</Text>
</Box>
</Grid>
</Box>
);
})}
</SimpleGrid>
</Card.Body>
</Card.Root>
))}
</VStack>
);
}
export default function CouponBuilderContent() {
const t = useTranslations("coupons");
const tCommon = useTranslations("common");
const locale = useLocale();
const messages = useMessages() as {
predictions?: { ["market-labels"]?: Record<string, string> };
};
const marketLabels = messages?.predictions?.["market-labels"];
const copy = React.useCallback(
(tr: string, en: string) => (locale === "tr" ? tr : en),
[locale],
);
const cardBg = useColorModeValue("white", "gray.800");
const mutedBg = useColorModeValue("gray.50", "whiteAlpha.50");
const borderColor = useColorModeValue("gray.200", "gray.700");
const { items, addItem, clearCoupon, removeItem, strategy, setStrategy } =
useCouponStore();
const upcomingQuery = useQueryMatches();
const finishedQuery = useQueryMatches();
const suggestCoupon = useSuggestCoupon();
const [activeStrategy, setActiveStrategy] = React.useState<CouponStrategy>(
strategy || "BALANCED",
);
const [selectedMatchIds, setSelectedMatchIds] = React.useState<string[]>([]);
const [showFinishedMatches, setShowFinishedMatches] = React.useState(false);
const [suggestedCoupon, setSuggestedCoupon] = React.useState<
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) {
upcomingQuery.mutate({
sport: "football",
status: "UPCOMING",
limit: 40,
});
}
}, [upcomingQuery.data, upcomingQuery.isPending, upcomingQuery.mutate]);
React.useEffect(() => {
if (
showFinishedMatches &&
!finishedQuery.data &&
!finishedQuery.isPending
) {
finishedQuery.mutate({
sport: "football",
status: "FINISHED",
limit: 20,
});
}
}, [
finishedQuery.data,
finishedQuery.isPending,
finishedQuery.mutate,
showFinishedMatches,
]);
React.useEffect(() => {
const coupon = suggestedCoupon;
if (!coupon?.bets?.length) return;
clearCoupon();
coupon.bets.forEach((bet) =>
addItem({
matchId: bet.match_id,
matchName: bet.match_name,
market: bet.market,
pick: bet.pick,
odd: bet.odds || 0,
}),
);
}, [addItem, clearCoupon, suggestedCoupon]);
const leagueGroups = upcomingQuery.data?.data ?? [];
const finishedLeagueGroups = finishedQuery.data?.data ?? [];
const allMatches = React.useMemo(
() => leagueGroups.flatMap((l) => l.matches),
[leagueGroups],
);
const allFinishedMatches = React.useMemo(
() => finishedLeagueGroups.flatMap((l) => l.matches),
[finishedLeagueGroups],
);
const selectedMatches = React.useMemo(() => {
const set = new Set(selectedMatchIds);
return allMatches.filter((m) => set.has(m.id));
}, [allMatches, selectedMatchIds]);
const suggestedBets: SuggestedCouponBetDto[] = suggestedCoupon?.bets ?? [];
const suggestErrorMessage =
suggestCoupon.error instanceof ApiError
? suggestCoupon.error.message
: suggestCoupon.error instanceof Error
? suggestCoupon.error.message
: undefined;
const strategies = [
{
key: "SAFE" as CouponStrategy,
label: t("strategy-safe"),
description: t("strategy-safe-desc"),
},
{
key: "BALANCED" as CouponStrategy,
label: t("strategy-balanced"),
description: t("strategy-balanced-desc"),
},
{
key: "AGGRESSIVE" as CouponStrategy,
label: t("strategy-aggressive"),
description: t("strategy-aggressive-desc"),
},
{
key: "VALUE" as CouponStrategy,
label: t("strategy-value"),
description: t("strategy-value-desc"),
},
];
const toggleMatchSelection = (matchId: string) =>
setSelectedMatchIds((c) =>
c.includes(matchId) ? c.filter((id) => id !== matchId) : [...c, matchId],
);
const handleSuggest = () => {
setStrategy(activeStrategy);
// If no matches selected, send all upcoming matches for AI to choose from
const matchIdsToSend =
selectedMatchIds.length > 0
? selectedMatchIds
: allMatches.map((m) => m.id); // Send all matches from bulletin
suggestCoupon.mutate(
{
matchIds: matchIdsToSend,
strategy: activeStrategy,
maxMatches: matchCount, // User's desired coupon size
},
{
onSuccess: (response) => {
setSuggestedCoupon(normalizeSmartCouponResult(response));
},
onError: () => {
setSuggestedCoupon(undefined);
},
},
);
};
const handleRefresh = () => {
upcomingQuery.reset();
upcomingQuery.mutate({ sport: "football", status: "UPCOMING", limit: 40 });
if (showFinishedMatches) {
finishedQuery.reset();
finishedQuery.mutate({
sport: "football",
status: "FINISHED",
limit: 20,
});
}
};
const getStoreItemLabel = (item: CouponItemDto) =>
`${marketLabels?.[item.market] || item.market}: ${item.pick}`;
const finishedMatchCountLabel = copy("Bitmis Mac", "Finished Matches");
const finishedMatchCountHelp = copy(
"Istege bagli referans listesi. Bu maclar kupon tahminine asla dahil edilmez.",
"Optional reference list of finished football matches. These are never used for coupon prediction.",
);
const finishedBadge = copy("Bitti", "Finished");
const predictionLocked = copy("Tahmine Kapali", "Prediction Locked");
const readOnlyShort = copy("Salt okunur", "Read only");
const matchState = copy("Mac Durumu", "Match State");
const finishedReferenceOnly = copy("Sadece referans", "Reference only");
const finishedMatchesTitle = copy("Bitmis Maclar", "Finished Matches");
const finishedMatchesHelp = copy(
"Bu maclar sadece referans icin gosterilir. Secilemezler ve backend tarafinda tahmin olusmadan once kesin olarak filtrelenirler.",
"These matches are shown only for reference. They cannot be selected and are filtered out on the backend before any coupon prediction is created.",
);
const finishedMatchesSubtitle = copy(
"Opsiyonel arsiv gorunumu. Skorlar ve mac sonu istatistikleri kupon tahmin akisina hicbir zaman gonderilmez.",
"Optional archive view. Scores and post-match stats are never sent into the coupon prediction flow.",
);
const showFinishedMatchesLabel = copy(
"Bitmis maclari goster",
"Show finished matches",
);
const hideFinishedMatchesLabel = copy(
"Bitmis maclari gizle",
"Hide finished matches",
);
const noFinishedMatchesLabel = copy(
"Bu gorunum icin bitmis futbol maci bulunamadi.",
"No finished football matches were found for the current view.",
);
return (
<SlideUp>
<Box>
<Heading as="h1" size="xl" fontWeight="bold" mb={2}>
{t("builder-title")}
</Heading>
<Text color="fg.muted" mb={6}>
{t("builder-subtitle")}
</Text>
<SimpleGrid columns={{ base: 1, md: 5 }} gap={3} mb={6}>
<StatCard
label={t("candidate-match-count")}
value={String(allMatches.length)}
helper={t("candidate-match-count-help")}
/>
<StatCard
label={finishedMatchCountLabel}
value={
showFinishedMatches ? String(allFinishedMatches.length) : "-"
}
helper={finishedMatchCountHelp}
accent={showFinishedMatches ? "orange.500" : undefined}
/>
<StatCard
label={t("selected-match-count")}
value={String(selectedMatchIds.length)}
helper={t("selected-match-count-help")}
accent={selectedMatchIds.length > 0 ? "teal.500" : undefined}
/>
<StatCard
label={t("suggested-bet-count")}
value={String(suggestedBets.length)}
helper={t("suggested-bet-count-help")}
accent={suggestedBets.length > 0 ? "green.500" : undefined}
/>
<StatCard
label={t("total-odds")}
value={
suggestedCoupon ? formatOdds(suggestedCoupon.total_odds) : "-"
}
helper={t("total-odds-help")}
accent={suggestedCoupon ? "purple.500" : undefined}
/>
</SimpleGrid>
<Flex
gap={6}
direction={{ base: "column", xl: "row" }}
align="flex-start"
>
<Box flex={1.7} minW={0}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
mb={4}
>
<Card.Body>
<Flex
justify="space-between"
align={{ base: "flex-start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
>
<VStack align="flex-start" gap={1}>
<HStack gap={2}>
<Icon as={LuShieldCheck} color="teal.500" boxSize={4.5} />
<Text fontWeight="semibold">
{t("candidate-pool-title")}
</Text>
<InfoIcon
content={t("candidate-pool-help")}
label={t("candidate-pool-title")}
/>
</HStack>
<Text fontSize="sm" color="fg.muted">
{t("candidate-pool-subtitle")}
</Text>
</VStack>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<LuRefreshCcw />
{tCommon("refresh")}
</Button>
</Flex>
</Card.Body>
</Card.Root>
{upcomingQuery.isPending ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="teal.500" />
</Flex>
) : leagueGroups.length > 0 ? (
<VStack gap={4} align="stretch">
<MatchGroups
groups={leagueGroups}
selectable
selectedIds={selectedMatchIds}
locale={locale}
onToggle={toggleMatchSelection}
badgeLabel={t("upcoming-badge")}
readOnlyLabel={readOnlyShort}
matchStateLabel={matchState}
finishedReferenceLabel={finishedReferenceOnly}
t={t}
tCommon={tCommon}
secondaryBadge={undefined}
/>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Body>
<Flex
justify="space-between"
align={{ base: "flex-start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
>
<VStack align="flex-start" gap={1}>
<HStack gap={2}>
<Icon as={LuLock} color="orange.500" boxSize={4.5} />
<Text fontWeight="semibold">
{finishedMatchesTitle}
</Text>
<InfoIcon
content={finishedMatchesHelp}
label={finishedMatchesTitle}
/>
</HStack>
<Text fontSize="sm" color="fg.muted">
{finishedMatchesSubtitle}
</Text>
</VStack>
<Button
variant="outline"
size="sm"
colorPalette="gray"
onClick={() => setShowFinishedMatches((v) => !v)}
>
{showFinishedMatches ? <LuEyeOff /> : <LuEye />}
{showFinishedMatches
? hideFinishedMatchesLabel
: showFinishedMatchesLabel}
</Button>
</Flex>
</Card.Body>
</Card.Root>
{showFinishedMatches ? (
finishedQuery.isPending ? (
<Flex justify="center" py={10}>
<Spinner size="md" color="orange.500" />
</Flex>
) : finishedLeagueGroups.length > 0 ? (
<MatchGroups
groups={finishedLeagueGroups}
selectable={false}
selectedIds={[]}
locale={locale}
onToggle={toggleMatchSelection}
badgeLabel={finishedBadge}
secondaryBadge={predictionLocked}
readOnlyLabel={readOnlyShort}
matchStateLabel={matchState}
finishedReferenceLabel={finishedReferenceOnly}
t={t}
tCommon={tCommon}
/>
) : (
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Body py={8}>
<Text textAlign="center" color="fg.muted">
{noFinishedMatchesLabel}
</Text>
</Card.Body>
</Card.Root>
)
) : null}
</VStack>
) : (
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Body py={10}>
<Text textAlign="center" color="fg.muted">
{t("no-upcoming-matches")}
</Text>
</Card.Body>
</Card.Root>
)}
</Box>
<Box
flex={1}
minW={0}
position={{ base: "relative", xl: "sticky" }}
top={{ xl: "88px" }}
>
<VStack gap={4} align="stretch">
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Header>
<HStack justify="space-between" align="center">
<VStack align="flex-start" gap={1}>
<HStack gap={2}>
<Icon as={LuTarget} color="teal.500" boxSize={4.5} />
<Heading as="h2" size="sm">
{t("my-coupon")}
</Heading>
<InfoIcon
label={t("my-coupon")}
content={t("my-coupon-help")}
/>
</HStack>
<Text fontSize="sm" color="fg.muted">
{selectedMatchIds.length > 0
? t("manual-selection-active")
: t("automatic-selection-active")}
</Text>
</VStack>
{items.length > 0 ? (
<Button
variant="ghost"
size="xs"
colorPalette="red"
onClick={clearCoupon}
>
<LuTrash />
{tCommon("clear")}
</Button>
) : null}
</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"
fontWeight="semibold"
mb={2}
>
{t("strategy")}
</Text>
<VStack align="stretch" gap={2} mb={4}>
{strategies.map((entry) => {
const active = activeStrategy === entry.key;
const palette = strategyPalette(entry.key);
return (
<Box
key={entry.key}
p={3}
borderWidth="1px"
borderColor={active ? `${palette}.400` : borderColor}
bg={active ? `${palette}.50` : mutedBg}
borderRadius="xl"
cursor="pointer"
onClick={() => setActiveStrategy(entry.key)}
>
<HStack justify="space-between" mb={1}>
<Badge
colorPalette={palette}
variant={active ? "solid" : "subtle"}
>
{entry.label}
</Badge>
{active ? <LuCheck color="currentColor" /> : null}
</HStack>
<Text fontSize="sm" color="fg.muted">
{entry.description}
</Text>
</Box>
);
})}
</VStack>
<Separator mb={4} />
{/* Match Count Input */}
<VStack align="stretch" gap={2} mb={4}>
<HStack justify="space-between">
<HStack gap={2}>
<Icon as={LuListChecks} color="purple.500" />
<Text fontWeight="semibold" fontSize="sm">
{t("match-count-label")}
</Text>
<InfoIcon
content={t("match-count-help")}
label={t("match-count-label")}
/>
</HStack>
<Badge colorPalette="purple" variant="subtle">
{matchCount}
</Badge>
</HStack>
<input
type="range"
min="2"
max="15"
value={matchCount}
onChange={(e) => setMatchCount(Number(e.target.value))}
style={{
width: "100%",
accentColor: "teal",
cursor: "pointer",
}}
/>
<HStack
justify="space-between"
fontSize="xs"
color="fg.muted"
>
<Text>2</Text>
<Text>
{t("match-count-auto", { count: allMatches.length })}
</Text>
<Text>15</Text>
</HStack>
</VStack>
<Separator mb={4} />
<VStack align="stretch" gap={3} mb={4}>
<HStack justify="space-between">
<HStack gap={2}>
<Icon as={LuLayers3} color="teal.500" />
<Text fontWeight="semibold">
{t("selected-matches-panel-title")}
</Text>
</HStack>
<Badge colorPalette="teal" variant="subtle">
{selectedMatchIds.length}
</Badge>
</HStack>
{selectedMatches.length > 0 ? (
<VStack align="stretch" gap={2}>
{selectedMatches.map((match: MatchResponseDto) => (
<Flex
key={match.id}
p={3}
bg={mutedBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
justify="space-between"
align="center"
gap={3}
>
<VStack align="flex-start" gap={0}>
<Text fontWeight="semibold" fontSize="sm">
{match.matchName}
</Text>
<Text fontSize="xs" color="fg.muted">
{formatDate(match.mstUtc, locale)}
</Text>
</VStack>
<Button
variant="ghost"
size="xs"
colorPalette="red"
onClick={() => toggleMatchSelection(match.id)}
>
{t("remove-match")}
</Button>
</Flex>
))}
</VStack>
) : (
<Box p={3} bg={mutedBg} borderRadius="xl">
<Text fontSize="sm" color="fg.muted">
{t("selected-matches-empty")}
</Text>
</Box>
)}
</VStack>
<Button
variant="solid"
colorPalette="teal"
size="lg"
width="full"
borderRadius="xl"
loading={suggestCoupon.isPending}
onClick={handleSuggest}
>
<LuSparkles />
{t("ai-suggest")}
</Button>
<Text fontSize="xs" color="fg.muted" mt={3}>
{selectedMatchIds.length > 0
? t("manual-selection-helper")
: t("automatic-selection-helper")}
</Text>
</>
)}
</Card.Body>
</Card.Root>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Header>
<HStack justify="space-between" align="center">
<HStack gap={2}>
<Icon as={LuListChecks} color="green.500" boxSize={4.5} />
<Heading as="h3" size="sm">
{t("suggested-bets-title")}
</Heading>
<InfoIcon
label={t("suggested-bets-title")}
content={t("suggested-bets-title-help")}
/>
</HStack>
{suggestedCoupon ? (
<Badge
colorPalette={strategyPalette(suggestedCoupon.strategy)}
variant="subtle"
>
{
strategies.find(
(e) => e.key === suggestedCoupon.strategy,
)?.label
}
</Badge>
) : null}
</HStack>
</Card.Header>
<Card.Body pt={0}>
{suggestedCoupon ? (
<VStack align="stretch" gap={3}>
<Grid templateColumns="repeat(2, minmax(0, 1fr))" gap={3}>
<Box p={3} bg={mutedBg} borderRadius="xl">
<Text fontSize="xs" color="fg.muted" mb={1}>
{t("expected-win-rate")}
</Text>
<Text
fontWeight="bold"
fontSize="lg"
color="green.500"
>
{formatPercent(
suggestedCoupon.expected_win_rate * 100,
0,
)}
</Text>
</Box>
<Box p={3} bg={mutedBg} borderRadius="xl">
<Text fontSize="xs" color="fg.muted" mb={1}>
{t("bet-count")}
</Text>
<Text fontWeight="bold" fontSize="lg">
{suggestedCoupon.match_count}
</Text>
</Box>
</Grid>
{suggestedBets.map((bet: SuggestedCouponBetDto) => (
<Box
key={`${bet.match_id}-${bet.market}-${bet.pick}`}
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">
{marketLabels?.[bet.market] || bet.market} {" "}
{bet.pick}
</Text>
</VStack>
<Badge colorPalette="teal" variant="solid">
{formatOdds(bet.odds)}
</Badge>
</Flex>
<Grid
templateColumns="repeat(2, minmax(0, 1fr))"
gap={2}
>
<Box>
<Text fontSize="xs" color="fg.muted" mb={1}>
{t("confidence-label")}
</Text>
<Text fontSize="sm" fontWeight="semibold">
{formatPercent(bet.confidence, 0)}
</Text>
</Box>
<Box>
<Text fontSize="xs" color="fg.muted" mb={1}>
{t("probability-label")}
</Text>
<Text fontSize="sm" fontWeight="semibold">
{formatPercent(bet.probability * 100, 0)}
</Text>
</Box>
</Grid>
<HStack gap={2} mt={3} flexWrap="wrap">
<Badge
colorPalette={riskPalette(bet.risk_level)}
variant="subtle"
>
{t("risk-label")}: {bet.risk_level}
</Badge>
<Badge
colorPalette={qualityPalette(bet.data_quality)}
variant="subtle"
>
{t("data-quality-label")}: {bet.data_quality}
</Badge>
</HStack>
</Box>
))}
{suggestedCoupon.rejected_matches?.length ? (
<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={2}>
{suggestedCoupon.rejected_matches.map((entry) => (
<Text
key={`${entry.match_id}-${entry.reason}`}
fontSize="sm"
color="fg.muted"
>
{entry.reason}
</Text>
))}
</VStack>
</Box>
) : null}
</VStack>
) : suggestErrorMessage ? (
<Box
p={4}
bg="red.50"
borderRadius="xl"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm" color="red.700">
{suggestErrorMessage}
</Text>
</Box>
) : (
<Box p={4} bg={mutedBg} borderRadius="xl">
<Text fontSize="sm" color="fg.muted">
{t("no-suggestion-yet")}
</Text>
</Box>
)}
</Card.Body>
</Card.Root>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
>
<Card.Header>
<HStack gap={2}>
<Icon as={LuTrophy} color="purple.500" boxSize={4.5} />
<Heading as="h3" size="sm">
{t("coupon")}
</Heading>
</HStack>
</Card.Header>
<Card.Body pt={0}>
{items.length > 0 ? (
<VStack gap={2} align="stretch">
{items.map((item: CouponItemDto) => (
<Flex
key={`${item.matchId}-${item.market}-${item.pick}`}
p={3}
bg={mutedBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
justify="space-between"
align="center"
gap={3}
>
<VStack gap={0} align="flex-start">
<Text fontSize="sm" fontWeight="bold">
{item.matchName || item.matchId}
</Text>
<Text fontSize="xs" color="fg.muted">
{getStoreItemLabel(item)}
</Text>
</VStack>
<HStack gap={2}>
<Badge colorPalette="purple" variant="subtle">
{formatOdds(item.odd)}
</Badge>
<Button
variant="ghost"
size="xs"
colorPalette="red"
onClick={() => removeItem(item.matchId)}
>
{tCommon("delete")}
</Button>
</HStack>
</Flex>
))}
</VStack>
) : (
<Text fontSize="sm" color="fg.muted">
{t("empty-coupon")}
</Text>
)}
</Card.Body>
</Card.Root>
</VStack>
</Box>
</Flex>
</Box>
</SlideUp>
);
}