This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user