1201 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|