428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Box,
|
|
Flex,
|
|
Heading,
|
|
Text,
|
|
SimpleGrid,
|
|
Card,
|
|
VStack,
|
|
HStack,
|
|
Badge,
|
|
Button,
|
|
} from "@chakra-ui/react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useColorModeValue } from "@/components/ui/color-mode";
|
|
import { SlideUp, StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
|
|
import { Skeleton } from "@/components/ui/feedback/skeleton";
|
|
import { MatchCard } from "@/components/matches";
|
|
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
|
|
import {
|
|
useUpcomingPredictions,
|
|
useValueBets,
|
|
} from "@/lib/api/predictions/use-hooks";
|
|
import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
|
|
import { useSession } from "next-auth/react";
|
|
import { LuTrendingUp, LuTarget, LuTicket, LuChartBar } from "react-icons/lu";
|
|
import { useRouter } from "next/navigation";
|
|
import type { LeagueWithMatchesDto, MatchResponseDto } from "@/lib/api/matches/types";
|
|
import type { MatchPredictionDto, ValueBetDto } from "@/lib/api/predictions/types";
|
|
|
|
// ========================
|
|
// Stats Card
|
|
// ========================
|
|
|
|
interface StatCardProps {
|
|
label: string;
|
|
value: string | number;
|
|
icon: React.ReactNode;
|
|
colorPalette?: string;
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
icon,
|
|
colorPalette = "primary",
|
|
}: StatCardProps) {
|
|
const cardBg = useColorModeValue(
|
|
"rgba(255, 255, 255, 0.75)",
|
|
"rgba(26, 32, 44, 0.65)",
|
|
);
|
|
const borderColor = useColorModeValue(
|
|
"rgba(255, 255, 255, 0.8)",
|
|
"rgba(255, 255, 255, 0.06)",
|
|
);
|
|
|
|
return (
|
|
<Card.Root
|
|
bg={cardBg}
|
|
borderColor={borderColor}
|
|
borderRadius="xl"
|
|
backdropFilter="blur(12px)"
|
|
_hover={{
|
|
transform: "translateY(-3px)",
|
|
shadow: "lg",
|
|
borderColor: `${colorPalette}.300`,
|
|
}}
|
|
transition="all 0.3s ease"
|
|
>
|
|
<Card.Body>
|
|
<HStack gap={4}>
|
|
<Flex
|
|
boxSize="48px"
|
|
bg={`${colorPalette}.subtle`}
|
|
borderRadius="xl"
|
|
align="center"
|
|
justify="center"
|
|
color={`${colorPalette}.fg`}
|
|
fontSize="xl"
|
|
shadow="sm"
|
|
>
|
|
{icon}
|
|
</Flex>
|
|
<VStack gap={0} align="flex-start">
|
|
<Text fontSize="2xl" fontWeight="900" lineHeight="1">
|
|
{value}
|
|
</Text>
|
|
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
|
|
{label}
|
|
</Text>
|
|
</VStack>
|
|
</HStack>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
);
|
|
}
|
|
|
|
// ========================
|
|
// Value Bet Mini Card
|
|
// ========================
|
|
|
|
interface ValueBetMiniCardProps {
|
|
matchName: string;
|
|
prediction: string;
|
|
odd: number;
|
|
expectedValue: number;
|
|
confidence: number;
|
|
}
|
|
|
|
function ValueBetMiniCard({
|
|
matchName,
|
|
prediction,
|
|
odd,
|
|
expectedValue,
|
|
confidence,
|
|
}: ValueBetMiniCardProps) {
|
|
const cardBg = useColorModeValue("white", "gray.800");
|
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
|
|
|
return (
|
|
<Box
|
|
p={3}
|
|
bg={cardBg}
|
|
borderWidth="1px"
|
|
borderColor={borderColor}
|
|
borderRadius="lg"
|
|
>
|
|
<Text fontSize="xs" color="fg.muted" truncate mb={1}>
|
|
{matchName}
|
|
</Text>
|
|
<Flex justify="space-between" align="center">
|
|
<Text fontSize="sm" fontWeight="bold">
|
|
{prediction}
|
|
</Text>
|
|
<HStack gap={2}>
|
|
<Badge
|
|
colorPalette="green"
|
|
variant="subtle"
|
|
fontSize="2xs"
|
|
borderRadius="full"
|
|
>
|
|
EV+ {(expectedValue * 100).toFixed(0)}%
|
|
</Badge>
|
|
<Badge
|
|
colorPalette="primary"
|
|
variant="subtle"
|
|
fontSize="2xs"
|
|
borderRadius="full"
|
|
>
|
|
{odd.toFixed(2)}
|
|
</Badge>
|
|
</HStack>
|
|
</Flex>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// ========================
|
|
// Dashboard Content
|
|
// ========================
|
|
|
|
export default function DashboardContent() {
|
|
const t = useTranslations("dashboard");
|
|
const tCoupons = useTranslations("coupons");
|
|
const router = useRouter();
|
|
const { data: session } = useSession();
|
|
|
|
const cardBg = useColorModeValue("white", "gray.800");
|
|
const borderColor = useColorModeValue("gray.100", "gray.700");
|
|
|
|
// Data fetching
|
|
const queryMatches = useQueryMatches();
|
|
const { data: upcomingData, isLoading: upcomingLoading } =
|
|
useUpcomingPredictions();
|
|
const { data: valueBetsData, isLoading: valueBetsLoading } = useValueBets();
|
|
const { data: statsData, isLoading: statsLoading } = useUserBettingStats();
|
|
|
|
// Trigger match fetch for today
|
|
if (!queryMatches.data && !queryMatches.isPending) {
|
|
queryMatches.mutate({ sport: "football", limit: 20 });
|
|
}
|
|
|
|
const todayMatches: MatchResponseDto[] = queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ?? [];
|
|
const upcomingPredictions: MatchPredictionDto[] = upcomingData?.data?.matches ?? [];
|
|
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
|
|
const userStats = statsData?.data;
|
|
|
|
const userName = session?.user?.name || "";
|
|
|
|
return (
|
|
<SlideUp>
|
|
<Box>
|
|
{/* Welcome Header */}
|
|
<Box mb={6}>
|
|
<Heading as="h1" size="xl" fontWeight="bold">
|
|
{t("title")}
|
|
</Heading>
|
|
{userName && (
|
|
<Text color="fg.muted" mt={1}>
|
|
{t("welcome")},{" "}
|
|
<Text as="span" fontWeight="semibold" color="fg">
|
|
{userName}
|
|
</Text>{" "}
|
|
👋
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Stats Grid */}
|
|
<StaggerContainer>
|
|
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4} mb={8}>
|
|
<StaggerItem>
|
|
<StatCard
|
|
label={tCoupons("total-coupons")}
|
|
value={userStats?.totalCoupons ?? "—"}
|
|
icon={<LuTicket />}
|
|
colorPalette="primary"
|
|
/>
|
|
</StaggerItem>
|
|
<StaggerItem>
|
|
<StatCard
|
|
label={tCoupons("win-rate")}
|
|
value={
|
|
userStats?.winRate ? `${Math.round(userStats.winRate)}%` : "—"
|
|
}
|
|
icon={<LuTrendingUp />}
|
|
colorPalette="green"
|
|
/>
|
|
</StaggerItem>
|
|
<StaggerItem>
|
|
<StatCard
|
|
label={tCoupons("won")}
|
|
value={userStats?.wonBets ?? "—"}
|
|
icon={<LuTarget />}
|
|
colorPalette="teal"
|
|
/>
|
|
</StaggerItem>
|
|
<StaggerItem>
|
|
<StatCard
|
|
label={tCoupons("pending")}
|
|
value={userStats?.pendingBets ?? "—"}
|
|
icon={<LuChartBar />}
|
|
colorPalette="yellow"
|
|
/>
|
|
</StaggerItem>
|
|
</SimpleGrid>
|
|
</StaggerContainer>
|
|
|
|
{/* Two Column Layout */}
|
|
<Flex
|
|
gap={6}
|
|
direction={{ base: "column", lg: "row" }}
|
|
align="flex-start"
|
|
>
|
|
{/* Left Column — Today's Matches */}
|
|
<Box flex={2} minW={0}>
|
|
<Flex justify="space-between" align="center" mb={4}>
|
|
<Heading as="h2" size="md">
|
|
{t("todays-matches")}
|
|
</Heading>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
colorPalette="primary"
|
|
onClick={() => router.push("/matches")}
|
|
>
|
|
{t("view-all")} →
|
|
</Button>
|
|
</Flex>
|
|
|
|
{queryMatches.isPending ? (
|
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3} py={4}>
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} height="140px" borderRadius="xl" />
|
|
))}
|
|
</SimpleGrid>
|
|
) : todayMatches.length > 0 ? (
|
|
<StaggerContainer>
|
|
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
|
|
{todayMatches.slice(0, 6).map((match: MatchResponseDto) => (
|
|
<StaggerItem key={match.id}>
|
|
<MatchCard match={match} />
|
|
</StaggerItem>
|
|
))}
|
|
</SimpleGrid>
|
|
</StaggerContainer>
|
|
) : (
|
|
<Card.Root
|
|
bg={cardBg}
|
|
borderColor={borderColor}
|
|
borderRadius="xl"
|
|
>
|
|
<Card.Body>
|
|
<Flex justify="center" py={8}>
|
|
<Text color="fg.muted">{t("no-matches")}</Text>
|
|
</Flex>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Right Column — Predictions & Value Bets */}
|
|
<VStack gap={6} flex={1} align="stretch" minW={0}>
|
|
{/* Upcoming Predictions */}
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
|
<Card.Header pb={2}>
|
|
<Flex justify="space-between" align="center">
|
|
<Heading as="h3" size="sm">
|
|
{t("upcoming-predictions")}
|
|
</Heading>
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
colorPalette="primary"
|
|
onClick={() => router.push("/predictions")}
|
|
>
|
|
{t("view-all")}
|
|
</Button>
|
|
</Flex>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
{upcomingLoading ? (
|
|
<VStack gap={2} align="stretch">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} height="50px" borderRadius="lg" />
|
|
))}
|
|
</VStack>
|
|
) : upcomingPredictions.length > 0 ? (
|
|
<VStack gap={2} align="stretch">
|
|
{upcomingPredictions.slice(0, 4).map((pred: MatchPredictionDto, idx: number) => (
|
|
<Box
|
|
key={idx}
|
|
p={2.5}
|
|
borderWidth="1px"
|
|
borderColor={borderColor}
|
|
borderRadius="lg"
|
|
cursor="pointer"
|
|
_hover={{ bg: "bg.muted" }}
|
|
onClick={() =>
|
|
router.push(`/matches/${pred.match_info.match_id}`)
|
|
}
|
|
>
|
|
<Text fontSize="xs" color="fg.muted" truncate>
|
|
{pred.match_info.home_team} vs{" "}
|
|
{pred.match_info.away_team}
|
|
</Text>
|
|
{pred.main_pick && (
|
|
<Flex justify="space-between" align="center" mt={1}>
|
|
<Text fontSize="sm" fontWeight="bold">
|
|
{pred.main_pick.pick}
|
|
</Text>
|
|
<Badge
|
|
colorPalette="primary"
|
|
variant="subtle"
|
|
fontSize="2xs"
|
|
borderRadius="full"
|
|
>
|
|
{Math.round(
|
|
pred.main_pick.calibrated_confidence ??
|
|
pred.main_pick.confidence,
|
|
)}
|
|
%
|
|
</Badge>
|
|
</Flex>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</VStack>
|
|
) : (
|
|
<Text
|
|
fontSize="sm"
|
|
color="fg.muted"
|
|
textAlign="center"
|
|
py={4}
|
|
>
|
|
{t("no-predictions")}
|
|
</Text>
|
|
)}
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
{/* Value Bets */}
|
|
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
|
<Card.Header pb={2}>
|
|
<Heading as="h3" size="sm">
|
|
{t("value-bets")}
|
|
</Heading>
|
|
</Card.Header>
|
|
<Card.Body pt={0}>
|
|
{valueBetsLoading ? (
|
|
<VStack gap={2} align="stretch">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} height="44px" borderRadius="lg" />
|
|
))}
|
|
</VStack>
|
|
) : valueBets.length > 0 ? (
|
|
<VStack gap={2} align="stretch">
|
|
{valueBets.slice(0, 5).map((vb: ValueBetDto, idx: number) => (
|
|
<ValueBetMiniCard
|
|
key={idx}
|
|
matchName={vb.matchName}
|
|
prediction={vb.prediction}
|
|
odd={vb.odd}
|
|
expectedValue={vb.expectedValue}
|
|
confidence={vb.confidence}
|
|
/>
|
|
))}
|
|
</VStack>
|
|
) : (
|
|
<Text
|
|
fontSize="sm"
|
|
color="fg.muted"
|
|
textAlign="center"
|
|
py={4}
|
|
>
|
|
{t("no-predictions")}
|
|
</Text>
|
|
)}
|
|
</Card.Body>
|
|
</Card.Root>
|
|
</VStack>
|
|
</Flex>
|
|
</Box>
|
|
</SlideUp>
|
|
);
|
|
}
|