first
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 4m0s

This commit is contained in:
2026-04-16 13:36:34 +03:00
parent de5e145c4e
commit fc7a1ba567
218 changed files with 32370 additions and 0 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
'use client';
import Footer from '@/components/layout/footer/footer';
import { Box, Flex } from '@chakra-ui/react';
function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<Flex minH='100vh' direction='column'>
<Box as='main'>{children}</Box>
<Footer />
</Flex>
);
}
export default AuthLayout;
+231
View File
@@ -0,0 +1,231 @@
"use client";
import {
Box,
Flex,
Heading,
Input,
Link as ChakraLink,
Text,
ClientOnly,
} from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Switch } from "@/components/ui/forms/switch";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import signInImage from "../../../../../public/assets/img/sign-in-image.png";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock } from "react-icons/bi";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useRouter } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().required(),
});
type SignInForm = yup.InferType<typeof schema>;
const defaultValues = {
email: "test@test.com.tr",
password: "test1234",
};
function SignInPage() {
const t = useTranslations();
const router = useRouter();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignInForm>({
resolver: yupResolver(schema),
mode: "onChange",
defaultValues,
});
const onSubmit = async (formData: SignInForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
toaster.error({
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally {
setLoading(false);
}
};
return (
<Box position="relative">
<Flex
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
w="100%"
maxW="1044px"
mx="auto"
justifyContent="space-between"
mb="30px"
pt={{ sm: "100px", md: "0px" }}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<Flex
direction="column"
w="100%"
background="transparent"
p="10"
mt={{ md: "150px", lg: "80px" }}
>
<Heading
color={{ base: "primary.400", _dark: "primary.200" }}
fontSize="32px"
mb="10px"
fontWeight="bold"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<Switch colorPalette="teal" label={t("auth.remember-me")}>
{t("auth.remember-me")}
</Switch>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.dont-have-account")}
<ChakraLink
as={Link}
href="/signup"
color={{ base: "primary.400", _dark: "primary.200" }}
ms="5px"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-up")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
<Box
display={{ base: "none", md: "block" }}
overflowX="hidden"
h="100%"
w="40vw"
position="absolute"
right="0px"
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
);
}
export default SignInPage;
+219
View File
@@ -0,0 +1,219 @@
"use client";
import {
Box,
Flex,
Input,
Link as ChakraLink,
Text,
ClientOnly,
} from "@chakra-ui/react";
import signUpImage from "../../../../../public/assets/img/sign-up-image.png";
import { Button } from "@/components/ui/buttons/button";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock, BiUser } from "react-icons/bi";
import { Link } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { useRouter } from "next/navigation";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { authService } from "@/lib/api/example/auth/service";
import { useState } from "react";
const schema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
type SignUpForm = yup.InferType<typeof schema>;
function SignUpPage() {
const t = useTranslations();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: "onChange" });
const onSubmit = async (formData: SignUpForm) => {
setIsSubmitting(true);
try {
await authService.register({
email: formData.email,
password: formData.password,
firstName: formData.name,
lastName: "",
});
router.replace("/signin");
} catch {
// Error handled by api-service interceptor (toast + 422 display)
} finally {
setIsSubmitting(false);
}
return formData;
};
return (
<Box>
<Box
position="absolute"
minH={{ base: "70vh", md: "50vh" }}
w={{ md: "calc(100vw - 50px)" }}
borderRadius={{ md: "15px" }}
left="0"
right="0"
bgRepeat="no-repeat"
overflow="hidden"
zIndex="-1"
top="0"
bgImage={`url(${signUpImage.src})`}
bgSize="cover"
mx={{ md: "auto" }}
mt={{ md: "14px" }}
/>
<Flex
w="full"
h="full"
direction="column"
alignItems="center"
justifyContent="center"
>
<Text
fontSize={{ base: "2xl", md: "3xl", lg: "4xl" }}
color="white"
fontWeight="bold"
mt={{ base: "2rem", md: "4.5rem", "2xl": "6.5rem" }}
mb={{ base: "2rem", md: "3rem", "2xl": "4rem" }}
>
{t("auth.create-an-account-now")}
</Text>
<Flex
direction="column"
w={{ base: "100%", md: "445px" }}
background="transparent"
borderRadius="15px"
p="10"
mx={{ base: "100px" }}
bg="bg.panel"
boxShadow="0 20px 27px 0 rgb(0 0 0 / 5%)"
mb="8"
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
flexDirection="column"
alignItems="center"
justifyContent="start"
w="100%"
>
<Field
mb="24px"
label={t("name")}
errorText={errors.name?.message}
invalid={!!errors.name}
>
<InputGroup w="full" startElement={<BiUser size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("name")}
size="lg"
{...register("name")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
type="submit"
bg="primary.400"
color="white"
fontWeight="bold"
w="100%"
h="45px"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
loading={isSubmitting}
>
{isSubmitting ? t("auth.registering") : t("auth.sign-up")}
</Button>
</ClientOnly>
</Field>
</Flex>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.already-have-an-account")}
<ChakraLink
as={Link}
color={{ base: "primary.400", _dark: "primary.200" }}
ml="2"
href="/signin"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-in")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
</Box>
);
}
export default SignUpPage;
@@ -0,0 +1,5 @@
import { notFound } from 'next/navigation';
export default function CatchAllPage() {
notFound();
}
+7
View File
@@ -0,0 +1,7 @@
import React from 'react';
function AboutPage() {
return <div>AboutPage</div>;
}
export default AboutPage;
+15
View File
@@ -0,0 +1,15 @@
import { getTranslations } from "next-intl/server";
import AdminContent from "@/components/admin/admin-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("admin.title")} | Suggest Bet`,
description:
"Admin panel for managing users, monitoring predictions, and system overview.",
};
}
export default function AdminPage() {
return <AdminContent />;
}
+14
View File
@@ -0,0 +1,14 @@
import { getTranslations } from "next-intl/server";
import AnalysisContent from "@/components/analysis/analysis-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("analysis.title")} | Suggest Bet`,
description: "AI-powered multi-match analysis for coupon generation.",
};
}
export default function AnalysisPage() {
return <AnalysisContent />;
}
@@ -0,0 +1,15 @@
import { getTranslations } from "next-intl/server";
import CouponBuilderContent from "@/components/coupons/coupon-builder-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("coupons.builder-title")} | Suggest Bet`,
description:
"Build your coupon with AI-powered suggestions. Choose your strategy and let AI optimize your bets.",
};
}
export default function CouponBuilderPage() {
return <CouponBuilderContent />;
}
@@ -0,0 +1,15 @@
import { getTranslations } from "next-intl/server";
import CouponHistoryContent from "@/components/coupons/coupon-history-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("coupons.history-title")} | Suggest Bet`,
description:
"View your coupon history, track wins and losses, and analyze your betting performance.",
};
}
export default function CouponHistoryPage() {
return <CouponHistoryContent />;
}
@@ -0,0 +1,16 @@
import { getTranslations } from "next-intl/server";
import DashboardContent from "@/components/dashboard/dashboard-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("dashboard.title")} | Suggest Bet`,
description:
"Your personalized betting dashboard with predictions, value bets, and match insights.",
};
}
export default function DashboardPage() {
return <DashboardContent />;
}
+14
View File
@@ -0,0 +1,14 @@
import { getTranslations } from "next-intl/server";
import H2HContent from "@/components/h2h/h2h-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("matches.head-to-head")} | Suggest Bet`,
description: "Compare two teams and view their head-to-head match history.",
};
}
export default function H2HPage() {
return <H2HContent />;
}
+16
View File
@@ -0,0 +1,16 @@
import { getTranslations } from "next-intl/server";
import HomeContent from "@/components/home/home-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("home")} | Suggest Bet`,
description:
"AI-powered betting predictions. Analyze matches, discover value bets, and build winning coupons.",
};
}
export default function Home() {
return <HomeContent />;
}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { Container, Flex } from '@chakra-ui/react';
import Header from '@/components/layout/header/header';
import Footer from '@/components/layout/footer/footer';
import BackToTop from '@/components/ui/back-to-top';
function MainLayout({ children }: { children: React.ReactNode }) {
return (
<Flex minH='100vh' direction='column'>
<Header />
<Container as='main' maxW='8xl' flex='1' py={4}>
{children}
</Container>
<BackToTop />
<Footer />
</Flex>
);
}
export default MainLayout;
+14
View File
@@ -0,0 +1,14 @@
import { getTranslations } from "next-intl/server";
import LeaguesContent from "@/components/leagues/leagues-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("leagues.title")} | Suggest Bet`,
description: "Browse football and basketball leagues, countries, and teams.",
};
}
export default function LeaguesPage() {
return <LeaguesContent />;
}
@@ -0,0 +1,14 @@
import { getTranslations } from "next-intl/server";
import MatchDetailContent from "@/components/matches/match-detail-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("matches.match-details")} | Suggest Bet`,
};
}
export default function MatchDetailPage() {
return <MatchDetailContent />;
}
+16
View File
@@ -0,0 +1,16 @@
import { getTranslations } from "next-intl/server";
import MatchesContent from "@/components/matches/matches-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("matches.title")} | Suggest Bet`,
description:
"Browse and analyze upcoming football and basketball matches with AI predictions.",
};
}
export default function MatchesPage() {
return <MatchesContent />;
}
@@ -0,0 +1,15 @@
import { getTranslations } from "next-intl/server";
import PredictionsContent from "@/components/predictions/predictions-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("predictions.title")} | Suggest Bet`,
description:
"AI-powered match predictions with confidence scores, value bets, and prediction history.",
};
}
export default function PredictionsPage() {
return <PredictionsContent />;
}
+15
View File
@@ -0,0 +1,15 @@
import { getTranslations } from "next-intl/server";
import ProfileContent from "@/components/profile/profile-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("profile.title")} | Suggest Bet`,
description:
"Manage your profile, view account info, and track your betting statistics.",
};
}
export default function ProfilePage() {
return <ProfileContent />;
}
@@ -0,0 +1,15 @@
import { getTranslations } from "next-intl/server";
import SporTotoContent from "@/components/spor-toto/spor-toto-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("spor-toto.title")} | Suggest Bet`,
description:
"Spor Toto predictions with AI-powered analysis. Generate optimized system coupons with contrarian parimutuel strategy.",
};
}
export default function SporTotoPage() {
return <SporTotoContent />;
}
@@ -0,0 +1,13 @@
import { getTranslations } from "next-intl/server";
import TeamDetailContent from "@/components/teams/team-detail-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("nav.teams")} | Suggest Bet`,
};
}
export default function TeamDetailPage() {
return <TeamDetailContent />;
}
+14
View File
@@ -0,0 +1,14 @@
import { getTranslations } from "next-intl/server";
import TeamsContent from "@/components/teams/teams-content";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("nav.teams")} | Suggest Bet`,
description: "Search and explore football teams, view match history and stats.",
};
}
export default function TeamsPage() {
return <TeamsContent />;
}
+160
View File
@@ -0,0 +1,160 @@
/* ═══════════════════════════════════════════════════════
Suggest-Bet — Global CSS
Premium animations, gradients, and utility keyframes
═══════════════════════════════════════════════════════ */
html {
scroll-behavior: smooth;
}
body {
overflow-x: hidden;
}
/* ──────────────────────────────────
Custom Animation Keyframes
────────────────────────────────── */
/* Pulsing live indicator */
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
}
/* Glow ring for CTAs */
@keyframes glow-ring {
0% { box-shadow: 0 0 0 0 rgba(56, 178, 172, 0.4); }
70% { box-shadow: 0 0 0 12px rgba(56, 178, 172, 0); }
100% { box-shadow: 0 0 0 0 rgba(56, 178, 172, 0); }
}
/* Shimmer for skeleton loading */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Gradient background shift */
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Sparkle float */
@keyframes sparkle-float {
0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-120px) rotate(360deg); opacity: 0; }
}
/* Subtle float for decorative elements */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* Gradient text shimmer */
@keyframes text-shimmer {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* Rotate for spinners */
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Fade in up — CSS fallback for non-JS */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ──────────────────────────────────
Utility Classes
────────────────────────────────── */
.animate-pulse { animation: pulse 1.5s ease-in-out infinite; }
.animate-glow { animation: glow-ring 2s infinite; }
.animate-shimmer {
background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.08) 50%, transparent 75%);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
.animate-float { animation: float 3s ease-in-out infinite; }
.animate-spin-slow { animation: spin-slow 8s linear infinite; }
/* Gradient mesh background */
.gradient-mesh {
background:
radial-gradient(at 20% 20%, rgba(56, 178, 172, 0.15) 0%, transparent 50%),
radial-gradient(at 80% 80%, rgba(128, 90, 213, 0.12) 0%, transparent 50%),
radial-gradient(at 50% 50%, rgba(66, 153, 225, 0.08) 0%, transparent 70%);
}
/* Dark mode gradient mesh */
[data-theme="dark"] .gradient-mesh,
.dark .gradient-mesh {
background:
radial-gradient(at 20% 20%, rgba(56, 178, 172, 0.08) 0%, transparent 50%),
radial-gradient(at 80% 80%, rgba(128, 90, 213, 0.06) 0%, transparent 50%),
radial-gradient(at 50% 50%, rgba(66, 153, 225, 0.04) 0%, transparent 70%);
}
/* Animated gradient text */
.gradient-text {
background: linear-gradient(135deg, #38B2AC, #805AD5, #4299E1, #38B2AC);
background-size: 300% 300%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: text-shimmer 4s ease infinite;
}
/* Glassmorphism card */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.3);
}
[data-theme="dark"] .glass,
.dark .glass {
background: rgba(26, 32, 44, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ──────────────────────────────────
Scrollbar Styling
────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.5);
}
/* ──────────────────────────────────
Selection Color
────────────────────────────────── */
::selection {
background: rgba(56, 178, 172, 0.3);
color: inherit;
}
+46
View File
@@ -0,0 +1,46 @@
import { Provider } from "@/components/ui/provider";
import { Bricolage_Grotesque } from "next/font/google";
import { hasLocale, NextIntlClientProvider } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { dir } from "i18next";
import "./global.css";
const bricolage = Bricolage_Grotesque({
variable: "--font-bricolage",
subsets: ["latin"],
});
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
return (
<html
lang={locale}
dir={dir(locale)}
suppressHydrationWarning
data-scroll-behavior="smooth"
>
<head>
{/* <link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' /> */}
<link rel="manifest" href="/favicon/site.webmanifest" />
</head>
<body className={bricolage.variable}>
<NextIntlClientProvider>
<Provider>{children}</Provider>
</NextIntlClientProvider>
</body>
</html>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Link } from '@/i18n/navigation';
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
import { getTranslations } from 'next-intl/server';
export default async function NotFoundPage() {
const t = await getTranslations();
return (
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
<VStack spaceY={6}>
<Heading
as='h1'
fontSize={{ base: '5xl', md: '6xl' }}
fontWeight='bold'
color={{ base: 'primary.600', _dark: 'primary.400' }}
>
{t('error.404')}
</Heading>
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
{t('error.not-found')}
</Text>
<Link href='/home' passHref>
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
{t('error.back-to-home')}
</Button>
</Link>
</VStack>
</Flex>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default async function Page() {
redirect('/home');
}
+123
View File
@@ -0,0 +1,123 @@
import { authService } from "@/lib/api/example/auth/service";
import NextAuth from "next-auth";
import type { NextAuthOptions } from "next-auth";
import type { JWT } from "next-auth/jwt";
import type { Session, User } from "next-auth";
import Credentials from "next-auth/providers/credentials";
function randomToken() {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
const authOptions: NextAuthOptions = {
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
try {
console.log("Starting authorization with:", {
email: credentials?.email,
});
if (!credentials?.email || !credentials?.password) {
throw new Error("Email ve şifre gereklidir.");
}
// Eğer mock mod aktifse backend'e gitme
if (isMockMode) {
console.log("Mock mode active, bypassing backend");
return {
id: credentials.email,
name: credentials.email.split("@")[0],
email: credentials.email,
accessToken: randomToken(),
refreshToken: randomToken(),
};
}
// Normal mod: backend'e istek at
console.log("Sending login request to backend...");
const res = await authService.login({
email: credentials.email,
password: credentials.password,
});
console.log(
"Backend response received:",
JSON.stringify(res, null, 2),
);
const response = res;
// Backend returns ApiResponse<TokenResponseDto>
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
if (!res.success || !response?.data?.accessToken) {
console.error("Login failed or no access token in response");
throw new Error(response?.message || "Giriş başarısız");
}
const { accessToken, refreshToken, user } = response.data;
console.log("Login successful, creating user session object");
return {
id: user.id,
name: user.firstName
? `${user.firstName} ${user.lastName || ""}`.trim()
: user.email.split("@")[0],
email: user.email,
accessToken,
refreshToken,
roles: user.roles || [],
};
} catch (error: unknown) {
console.error("Authorize error detailed:", error);
const err = error as Error & {
response?: { data: unknown; status: number };
};
if (err.response) {
console.error("Error response data:", err.response.data);
console.error("Error response status:", err.response.status);
}
throw new Error(
err.message || "An error occurred during authentication",
);
}
},
}),
],
callbacks: {
async jwt({ token, user }: { token: JWT; user?: User }) {
if (user) {
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
token.id = user.id;
token.roles = user.roles;
}
return token;
},
async session({ session, token }: { session: Session; token: JWT }) {
session.user.id = token.id;
session.user.roles = token.roles;
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
},
},
pages: {
signIn: "/signin",
error: "/signin",
},
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
BIN
View File
Binary file not shown.
+283
View File
@@ -0,0 +1,283 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
SimpleGrid,
Card,
VStack,
HStack,
Badge,
Spinner,
Button,
Separator,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import {
SlideUp,
StaggerContainer,
StaggerItem,
AnimatedCounter,
} from "@/components/motion";
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
import { useState } from "react";
type AdminTab = "overview" | "users";
// ========================
// Admin Stat Card
// ========================
interface AdminStatProps {
label: string;
value: number;
icon: React.ReactNode;
colorPalette: string;
}
function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<HStack gap={4}>
<Flex
boxSize="48px"
bg={`${colorPalette}.subtle`}
borderRadius="xl"
align="center"
justify="center"
color={`${colorPalette}.fg`}
fontSize="xl"
>
{icon}
</Flex>
<VStack gap={0} align="flex-start">
<Text fontSize="2xl" fontWeight="900" lineHeight="1">
<AnimatedCounter value={value} />
</Text>
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
{label}
</Text>
</VStack>
</HStack>
</Card.Body>
</Card.Root>
);
}
// ========================
// Admin Content
// ========================
export default function AdminContent() {
const t = useTranslations("admin");
const tCommon = useTranslations("common");
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const { data: analyticsData, isLoading: analyticsLoading } =
useAdminAnalytics();
const { data: usersData, isLoading: usersLoading } = useAdminUsers();
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
const users = (usersData?.data as AdminUserDto[] | undefined) ?? [];
const tabs: { key: AdminTab; label: string }[] = [
{ key: "overview", label: t("overview") },
{ key: "users", label: t("user-management") },
];
const getUserDisplayName = (user: AdminUserDto) => {
if (user.firstName && user.lastName)
return `${user.firstName} ${user.lastName}`;
if (user.firstName) return user.firstName;
return user.email.split("@")[0];
};
return (
<SlideUp>
<Box>
<Flex justify="space-between" align="center" mb={6}>
<VStack gap={1} align="flex-start">
<Heading as="h1" size="xl" fontWeight="bold">
{t("title")}
</Heading>
<Text color="fg.muted" fontSize="sm">
{t("subtitle")}
</Text>
</VStack>
<Badge
colorPalette="red"
variant="solid"
px={3}
py={1}
borderRadius="full"
>
<LuShield />
{t("admin-badge")}
</Badge>
</Flex>
{/* Tabs */}
<HStack gap={2} mb={6}>
{tabs.map((tab) => (
<Button
key={tab.key}
variant={activeTab === tab.key ? "solid" : "outline"}
colorPalette={activeTab === tab.key ? "primary" : "gray"}
size="sm"
borderRadius="full"
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</Button>
))}
</HStack>
{/* Overview Tab */}
{activeTab === "overview" &&
(analyticsLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : (
<StaggerContainer>
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4} mb={8}>
<StaggerItem>
<AdminStat
label={t("total-users")}
value={analytics?.totalUsers ?? 0}
icon={<LuUsers />}
colorPalette="primary"
/>
</StaggerItem>
<StaggerItem>
<AdminStat
label={t("total-predictions")}
value={analytics?.totalPredictions ?? 0}
icon={<LuChartBar />}
colorPalette="green"
/>
</StaggerItem>
<StaggerItem>
<AdminStat
label={t("active-users")}
value={analytics?.activeUsers ?? 0}
icon={<LuActivity />}
colorPalette="orange"
/>
</StaggerItem>
<StaggerItem>
<AdminStat
label={t("total-coupons")}
value={analytics?.totalCoupons ?? 0}
icon={<LuShield />}
colorPalette="purple"
/>
</StaggerItem>
</SimpleGrid>
</StaggerContainer>
))}
{/* Users Tab */}
{activeTab === "users" &&
(usersLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : users.length > 0 ? (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<VStack gap={0} align="stretch">
{/* Table Header */}
<Flex
px={4}
py={2}
bg="bg.muted"
borderRadius="lg"
mb={2}
fontWeight="semibold"
fontSize="xs"
color="fg.muted"
>
<Text flex={2}>{t("user-name")}</Text>
<Text flex={2}>{t("user-email")}</Text>
<Text flex={1} textAlign="center">
{t("user-role")}
</Text>
<Text flex={1} textAlign="center">
{t("user-status")}
</Text>
</Flex>
{/* User Rows */}
{users.map((user: AdminUserDto, idx: number) => (
<Box key={user.id ?? idx}>
{idx > 0 && <Separator />}
<Flex
px={4}
py={3}
align="center"
_hover={{ bg: "bg.muted" }}
borderRadius="lg"
>
<Text
flex={2}
fontSize="sm"
fontWeight="medium"
truncate
>
{getUserDisplayName(user)}
</Text>
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
{user.email}
</Text>
<Flex flex={1} justify="center">
<Badge
colorPalette={
user.role === "ADMIN" ? "red" : "gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{user.role || "User"}
</Badge>
</Flex>
<Flex flex={1} justify="center">
<Badge
colorPalette={user.isActive ? "green" : "gray"}
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{user.isActive
? tCommon("active")
: tCommon("inactive")}
</Badge>
</Flex>
</Flex>
</Box>
))}
</VStack>
</Card.Body>
</Card.Root>
) : (
<Flex justify="center" py={16}>
<Text color="fg.muted">{t("no-users")}</Text>
</Flex>
))}
</Box>
</SlideUp>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as AdminContent } from "./admin-content";
@@ -0,0 +1,237 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Card,
VStack,
HStack,
Badge,
Spinner,
Button,
SimpleGrid,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import {
useAnalyzeMatches,
useAnalysisHistory,
} from "@/lib/api/analysis/use-hooks";
import { useQueryMatches } from "@/lib/api/matches/use-hooks";
import type { LeagueWithMatchesDto } from "@/lib/api/matches/types";
import { LuSparkles, LuClock, LuCheck } from "react-icons/lu";
import { useState } from "react";
import { toaster } from "@/components/ui/feedback/toaster";
export default function AnalysisContent() {
const t = useTranslations("analysis");
const tCommon = useTranslations("common");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const [selectedMatchIds, setSelectedMatchIds] = useState<string[]>([]);
const upcomingMatches = useQueryMatches();
const analyzeMutation = useAnalyzeMatches();
const historyQuery = useAnalysisHistory();
const toast = (opts: { title: string; status: string }) =>
toaster.create({
title: opts.title,
type: opts.status as
| "success"
| "warning"
| "error"
| "info"
| "loading",
});
const toggleMatch = (id: string) => {
setSelectedMatchIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleAnalyze = async () => {
if (selectedMatchIds.length < 2) {
toast({
title: t("select-at-least-2"),
status: "warning",
});
return;
}
await analyzeMutation.mutateAsync({ matchIds: selectedMatchIds });
toast({
title: t("analysis-complete"),
status: "success",
});
historyQuery.refetch();
};
const allMatches: { id: string; home: string; away: string; date: string }[] =
upcomingMatches.data?.data
?.flatMap((league: LeagueWithMatchesDto) =>
league.matches?.map((m) => ({
id: m.id,
home: m.homeTeam?.name || "",
away: m.awayTeam?.name || "",
date: m.mstUtc ? new Date(m.mstUtc).toLocaleDateString() : "",
})),
)
.filter(Boolean) || [];
return (
<SlideUp>
<Box maxW="6xl" mx="auto">
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
{t("title")}
</Heading>
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
{/* Match Selection */}
<Box flex={2}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Header>
<Flex justify="space-between" align="center">
<Heading as="h3" size="sm">
{t("select-matches")}
</Heading>
<Badge
colorScheme={
selectedMatchIds.length > 0 ? "primary" : "gray"
}
>
{selectedMatchIds.length} {t("selected")}
</Badge>
</Flex>
</Card.Header>
<Card.Body pt={0}>
{upcomingMatches.isPending ? (
<Flex justify="center" py={6}>
<Spinner size="sm" />
</Flex>
) : (
<VStack gap={2}>
{allMatches.map((m) => {
const isSelected = selectedMatchIds.includes(m.id);
return (
<Flex
key={m.id}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={isSelected ? "primary.500" : borderColor}
bg={isSelected ? "primary.50" : "transparent"}
_dark={isSelected ? { bg: "primary.900" } : undefined}
justify="space-between"
align="center"
cursor="pointer"
onClick={() => toggleMatch(m.id)}
>
<HStack gap={3}>
<Box
boxSize="20px"
borderRadius="sm"
borderWidth="2px"
borderColor={
isSelected ? "primary.500" : "gray.300"
}
bg={isSelected ? "primary.500" : "transparent"}
display="flex"
alignItems="center"
justifyContent="center"
color="white"
>
{isSelected ? <LuCheck size="12" /> : null}
</Box>
<Text fontSize="sm" fontWeight="medium">
{m.home} vs {m.away}
</Text>
</HStack>
<Text fontSize="xs" color="fg.muted">
{m.date}
</Text>
</Flex>
);
})}
</VStack>
)}
<Button
mt={4}
w="full"
onClick={handleAnalyze}
loading={analyzeMutation.isPending}
disabled={selectedMatchIds.length < 2}
>
<LuSparkles /> {t("analyze-matches")}
</Button>
</Card.Body>
</Card.Root>
</Box>
{/* Analysis History */}
<Box flex={1}>
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Header>
<Heading as="h3" size="sm">
<HStack gap={2}>
<LuClock />
<Text>{t("history")}</Text>
</HStack>
</Heading>
</Card.Header>
<Card.Body pt={0}>
{historyQuery.isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="sm" />
</Flex>
) : historyQuery.data?.data?.analyses &&
historyQuery.data.data.analyses.length > 0 ? (
<VStack gap={3}>
{historyQuery.data.data.analyses.map(
(a: {
id: string;
matchIds: string[];
createdAt: string;
}) => (
<Card.Root
key={a.id}
size="sm"
borderWidth="1px"
borderColor={borderColor}
>
<Card.Body>
<VStack align="start" gap={1}>
<Text fontSize="sm" fontWeight="semibold">
{a.matchIds.length} {t("matches-analyzed")}
</Text>
<Text fontSize="xs" color="fg.muted">
{new Date(a.createdAt).toLocaleString()}
</Text>
</VStack>
</Card.Body>
</Card.Root>
),
)}
</VStack>
) : (
<Text color="fg.muted" textAlign="center" py={6}>
{t("no-history")}
</Text>
)}
</Card.Body>
</Card.Root>
</Box>
</Flex>
</Box>
</SlideUp>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Field } from "@/components/ui/forms/field";
import { InputGroup } from "@/components/ui/forms/input-group";
import { PasswordInput } from "@/components/ui/forms/password-input";
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogHeader,
DialogRoot,
DialogTitle,
} from "@/components/ui/overlays/dialog";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
import { MdMail } from "react-icons/md";
import { BiLock } from "react-icons/bi";
import { Link } from "@/i18n/navigation";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(6).required(),
});
type LoginForm = yup.InferType<typeof schema>;
interface LoginModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
const t = useTranslations();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<LoginForm>({
resolver: yupResolver(schema),
mode: "onChange",
});
const onSubmit = async (formData: LoginForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
onOpenChange(false);
toaster.success({
title: t("auth.login-success") || "Login successful!",
type: "success",
});
} catch (error) {
toaster.error({
title: (error as Error).message || "Login failed!",
type: "error",
});
} finally {
setLoading(false);
}
};
return (
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Heading size="lg" color="primary.500">
{t("auth.sign-in")}
</Heading>
</DialogTitle>
<DialogCloseTrigger />
</DialogHeader>
<DialogBody>
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
<VStack gap={4}>
<Field
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="md"
fontSize="sm"
type="text"
placeholder={t("email")}
{...register("email")}
/>
</InputGroup>
</Field>
<Field
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="md"
fontSize="sm"
placeholder={t("password")}
{...register("password")}
/>
</InputGroup>
</Field>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
color="white"
_hover={{ bg: "primary.500" }}
>
{t("auth.sign-in")}
</Button>
<Text fontSize="sm" color="fg.muted">
{t("auth.dont-have-account")}{" "}
<Link
href="/signup"
style={{
color: "var(--chakra-colors-primary-500)",
fontWeight: "bold",
}}
>
{t("auth.sign-up")}
</Link>
</Text>
</VStack>
</Box>
</DialogBody>
</DialogContent>
</DialogRoot>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Card,
VStack,
HStack,
Badge,
Spinner,
Separator,
Button,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, StaggerContainer, StaggerItem } from "@/components/motion";
import { useCouponHistory } from "@/lib/api/coupons/use-hooks";
import type { CouponResponseDto, CouponItemDto } from "@/lib/api/coupons/types";
import { useState } from "react";
type FilterType = "all" | "pending" | "won" | "lost";
export default function CouponHistoryContent() {
const t = useTranslations("coupons");
const tCommon = useTranslations("common");
const [filter, setFilter] = useState<FilterType>("all");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const { data, isLoading } = useCouponHistory();
const historyData = data?.data as
| { coupons?: CouponResponseDto[] }
| undefined;
const allCoupons: CouponResponseDto[] = historyData?.coupons ?? [];
const filteredCoupons =
filter === "all"
? allCoupons
: allCoupons.filter(
(c: CouponResponseDto) => c.status?.toLowerCase() === filter,
);
const statusColors: Record<string, string> = {
pending: "yellow",
won: "green",
lost: "red",
};
const filters: { key: FilterType; label: string }[] = [
{ key: "all", label: tCommon("all") },
{ key: "pending", label: t("pending") },
{ key: "won", label: t("won") },
{ key: "lost", label: t("lost") },
];
return (
<SlideUp>
<Box>
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
{t("history-title")}
</Heading>
{/* Filters */}
<HStack gap={2} mb={6} overflowX="auto" pb={1}>
{filters.map((f) => (
<Button
key={f.key}
variant={filter === f.key ? "solid" : "outline"}
colorPalette={filter === f.key ? "primary" : "gray"}
size="sm"
borderRadius="full"
onClick={() => setFilter(f.key)}
flexShrink={0}
>
{f.label}
</Button>
))}
</HStack>
{isLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : filteredCoupons.length > 0 ? (
<StaggerContainer>
<VStack gap={4} align="stretch">
{filteredCoupons.map((coupon: CouponResponseDto, idx: number) => (
<StaggerItem key={coupon.id ?? idx}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Header pb={2}>
<Flex justify="space-between" align="center">
<HStack gap={2}>
<Text fontSize="sm" fontWeight="bold">
{t("coupon")} #{coupon.id?.slice(-6) || idx + 1}
</Text>
<Badge
colorPalette={
statusColors[
coupon.status?.toLowerCase() ?? ""
] || "gray"
}
variant="subtle"
fontSize="xs"
borderRadius="full"
>
{coupon.status || "—"}
</Badge>
</HStack>
<VStack gap={0} align="flex-end">
<Text fontSize="xs" color="fg.muted">
{t("total-odd")}
</Text>
<Text fontSize="sm" fontWeight="bold">
{coupon.totalOdd?.toFixed(2) || "—"}
</Text>
</VStack>
</Flex>
</Card.Header>
<Card.Body pt={0}>
{coupon.items?.map(
(item: CouponItemDto, itemIdx: number) => (
<Box key={itemIdx}>
{itemIdx > 0 && <Separator my={2} />}
<Flex justify="space-between" align="center">
<VStack gap={0} align="flex-start">
<Text fontSize="xs" fontWeight="semibold">
{item.matchId}
</Text>
<Text fontSize="2xs" color="fg.muted">
{item.market}: {item.pick}
</Text>
</VStack>
<Badge
colorPalette="primary"
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{item.odd?.toFixed(2)}
</Badge>
</Flex>
</Box>
),
)}
{coupon.strategy && (
<Flex
mt={3}
pt={2}
borderTopWidth="1px"
borderColor={borderColor}
justify="space-between"
>
<Text fontSize="2xs" color="fg.muted">
{t("strategy")}: {coupon.strategy}
</Text>
<Text fontSize="2xs" color="fg.muted">
{coupon.createdAt &&
new Date(coupon.createdAt).toLocaleDateString(
"tr-TR",
)}
</Text>
</Flex>
)}
</Card.Body>
</Card.Root>
</StaggerItem>
))}
</VStack>
</StaggerContainer>
) : (
<Flex justify="center" py={16}>
<Text color="fg.muted">{t("no-coupons")}</Text>
</Flex>
)}
</Box>
</SlideUp>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as CouponBuilderContent } from "./coupon-builder-content";
export { default as CouponHistoryContent } from "./coupon-history-content";
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as DashboardContent } from "./dashboard-content";
+335
View File
@@ -0,0 +1,335 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Card,
VStack,
HStack,
Badge,
Spinner,
Input,
Button,
} from "@chakra-ui/react";
import { InputGroup } from "@/components/ui/forms/input-group";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import { useSearchTeams, useHeadToHead } from "@/lib/api/leagues/use-hooks";
import type { TeamDto, HeadToHeadDto } from "@/lib/api/leagues/types";
import type { MatchResponseDto } from "@/lib/api/matches/types";
import { LuSearch, LuArrowLeftRight } from "react-icons/lu";
import { useState, useEffect } from "react";
import { useDebounce } from "@/hooks/use-debounce";
function TeamSearchInput({
label,
value,
onSelect,
}: {
label: string;
value: TeamDto | null;
onSelect: (team: TeamDto) => void;
}) {
const t = useTranslations("h2h");
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const searchTeams = useSearchTeams(
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" },
);
return (
<Box position="relative" w="full">
<Text fontWeight="semibold" mb={2}>
{label}
</Text>
<InputGroup startElement={<LuSearch />}>
<Input
value={value ? value.name : query}
onChange={(e) => {
setQuery(e.target.value);
if (value) onSelect(null as unknown as TeamDto);
}}
placeholder={t("search-team")}
/>
</InputGroup>
{debouncedQuery.length >= 2 && !value && searchTeams.data?.data && (
<Box
position="absolute"
top="full"
left={0}
right={0}
bg="bg.panel"
border="1px"
borderColor="border.muted"
borderRadius="md"
zIndex={10}
maxH="200px"
overflowY="auto"
>
{searchTeams.data.data.map((team: TeamDto) => (
<Flex
key={team.id}
px={3}
py={2}
cursor="pointer"
_hover={{ bg: "gray.100", _dark: { bg: "gray.700" } }}
onClick={() => onSelect(team)}
align="center"
gap={2}
>
{team.logo ? (
<img
src={team.logo}
width="20"
height="20"
style={{ borderRadius: "50%" }}
alt={team.name}
/>
) : null}
<Text fontSize="sm">{team.name}</Text>
{team.sport ? (
<Badge
size="xs"
colorScheme={team.sport === "football" ? "green" : "orange"}
>
{team.sport}
</Badge>
) : null}
</Flex>
))}
</Box>
)}
</Box>
);
}
export default function H2HContent() {
const t = useTranslations("h2h");
const tMatches = useTranslations("matches");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const [team1, setTeam1] = useState<TeamDto | null>(null);
const [team2, setTeam2] = useState<TeamDto | null>(null);
const [hasSearched, setHasSearched] = useState(false);
const h2h = useHeadToHead(
team1 && team2
? { team1: team1.id, team2: team2.id }
: { team1: "", team2: "" },
);
const handleSearch = () => {
if (team1 && team2) {
setHasSearched(true);
h2h.refetch();
}
};
const stats: { label: string; value: number; color: string }[] = h2h.data
?.data
? [
{
label: team1?.name || t("team1"),
value: h2h.data.data.team1Wins,
color: "green",
},
{
label: t("draws"),
value: h2h.data.data.draws,
color: "gray",
},
{
label: team2?.name || t("team2"),
value: h2h.data.data.team2Wins,
color: "blue",
},
]
: [];
return (
<SlideUp>
<Box maxW="5xl" mx="auto">
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
<HStack gap={2}>
<LuArrowLeftRight />
<Text>{t("title")}</Text>
</HStack>
</Heading>
{/* Team Selection */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Body>
<Flex
direction={{ base: "column", md: "row" }}
gap={4}
align="flex-end"
>
<Box flex={1}>
<TeamSearchInput
label={t("team-1")}
value={team1}
onSelect={(t) => setTeam1(t)}
/>
</Box>
<Box flex={1}>
<TeamSearchInput
label={t("team-2")}
value={team2}
onSelect={(t) => setTeam2(t)}
/>
</Box>
<Button
onClick={handleSearch}
disabled={!team1 || !team2}
minW="120px"
>
{t("compare")}
</Button>
</Flex>
</Card.Body>
</Card.Root>
{/* Results */}
{hasSearched && (
<>
{/* Stats Bar */}
{h2h.isLoading ? (
<Flex justify="center" py={8}>
<Spinner size="md" color="primary.500" />
</Flex>
) : h2h.data?.data ? (
<>
<Flex gap={4} mb={6} justify="center">
{stats.map((s) => (
<Card.Root
key={s.label}
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
flex={1}
maxW="200px"
>
<Card.Body textAlign="center">
<Text
fontSize="3xl"
fontWeight="bold"
color={`${s.color}.500`}
>
{s.value}
</Text>
<Text fontSize="sm" color="fg.muted" mt={1}>
{s.label}
</Text>
</Card.Body>
</Card.Root>
))}
</Flex>
{/* Match History */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Header>
<Heading as="h3" size="sm">
{tMatches("recent-matches")} (
{h2h.data.data.matches?.length ?? 0})
</Heading>
</Card.Header>
<Card.Body pt={0}>
<VStack gap={3}>
{(
h2h.data.data.matches as
| MatchResponseDto[]
| undefined
| null
)?.map((match: MatchResponseDto) => {
const isHomeTeam1 = match.homeTeam?.id === team1?.id;
// Backend returns scoreHome/scoreAway, not homeScore/awayScore
const homeScore = Number((match as any).scoreHome ?? 0);
const awayScore = Number((match as any).scoreAway ?? 0);
const homeWon =
(isHomeTeam1 && homeScore > awayScore) ||
(!isHomeTeam1 && awayScore > homeScore);
const isDraw = homeScore === awayScore;
// Parse mstUtc - can be bigint string from backend
const matchDate = match.mstUtc
? new Date(Number(match.mstUtc)).toLocaleDateString()
: "";
return (
<Flex
key={match.id}
p={3}
borderRadius="md"
bg={
isDraw
? "gray.50"
: homeWon
? "green.50"
: "red.50"
}
_dark={{
bg: isDraw
? "gray.750"
: homeWon
? "green.900"
: "red.900",
}}
justify="space-between"
align="center"
>
<Flex align="center" gap={3} flex={1}>
<Text fontSize="sm" fontWeight="medium">
{match.homeTeam?.name}
</Text>
<Badge
colorScheme={
isDraw ? "gray" : homeWon ? "green" : "red"
}
>
{homeScore ?? 0} - {awayScore ?? 0}
</Badge>
<Text fontSize="sm" fontWeight="medium">
{match.awayTeam?.name}
</Text>
</Flex>
<Text fontSize="xs" color="fg.muted">
{matchDate}
</Text>
</Flex>
);
})}
</VStack>
</Card.Body>
</Card.Root>
</>
) : (
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body textAlign="center" py={8}>
<Text color="fg.muted">{t("no-matches-found")}</Text>
</Card.Body>
</Card.Root>
)}
</>
)}
</Box>
</SlideUp>
);
}
+377
View File
@@ -0,0 +1,377 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Button,
SimpleGrid,
Card,
VStack,
HStack,
Icon,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useColorModeValue } from "@/components/ui/color-mode";
import { motion } from "framer-motion";
import {
ScrollSlideUp,
StaggerContainer,
StaggerItem,
AnimatedCounter,
Sparkles,
GradientOrb,
ScrollScaleIn,
springs,
} from "@/components/motion";
import { LuBrain, LuTrendingUp, LuTicket, LuRadio } from "react-icons/lu";
const MotionBox = motion.create(Box);
// ========================
// Feature Card — glassmorphic with hover glow
// ========================
interface FeatureCardProps {
icon: React.ReactNode;
title: string;
description: string;
colorPalette: string;
}
function FeatureCard({
icon,
title,
description,
colorPalette,
}: FeatureCardProps) {
const cardBg = useColorModeValue(
"rgba(255, 255, 255, 0.8)",
"rgba(26, 32, 44, 0.7)",
);
const borderColor = useColorModeValue(
"rgba(255, 255, 255, 0.6)",
"rgba(255, 255, 255, 0.06)",
);
return (
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="2xl"
backdropFilter="blur(12px)"
position="relative"
overflow="hidden"
_hover={{
transform: "translateY(-6px)",
shadow: "2xl",
borderColor: `${colorPalette}.400`,
}}
transition="all 0.4s cubic-bezier(0.25, 0.1, 0.25, 1)"
>
{/* Hover glow effect */}
<Box
position="absolute"
top="-50%"
left="-50%"
w="200%"
h="200%"
bg={`radial-gradient(circle at center, ${colorPalette === "primary" ? "rgba(56,178,172,0.06)" : colorPalette === "green" ? "rgba(72,187,120,0.06)" : colorPalette === "purple" ? "rgba(128,90,213,0.06)" : "rgba(245,101,101,0.06)"} 0%, transparent 70%)`}
opacity={0}
transition="opacity 0.4s"
_groupHover={{ opacity: 1 }}
pointerEvents="none"
/>
<Card.Body>
<VStack gap={4} align="flex-start">
<Flex
boxSize="56px"
bg={`${colorPalette}.subtle`}
borderRadius="xl"
align="center"
justify="center"
color={`${colorPalette}.fg`}
fontSize="2xl"
shadow="sm"
>
{icon}
</Flex>
<Box>
<Text fontSize="lg" fontWeight="bold" mb={1}>
{title}
</Text>
<Text fontSize="sm" color="fg.muted" lineHeight="tall">
{description}
</Text>
</Box>
</VStack>
</Card.Body>
</Card.Root>
);
}
// ========================
// Stat Block — with real counting animation
// ========================
interface StatBlockProps {
value: number;
label: string;
suffix?: string;
}
function StatBlock({ value, label, suffix }: StatBlockProps) {
return (
<VStack gap={1}>
<Text
fontSize={{ base: "3xl", md: "4xl" }}
fontWeight="900"
className="gradient-text"
>
<AnimatedCounter value={value} suffix={suffix} duration={2.5} />
</Text>
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
{label}
</Text>
</VStack>
);
}
// ========================
// Home Content — Premium
// ========================
export default function HomeContent() {
const t = useTranslations("landing");
const router = useRouter();
const heroBg = useColorModeValue(
"linear-gradient(135deg, #E6FFFA 0%, #C4F1F9 25%, #B2F5EA 50%, #81E6D9 75%, #4FD1C5 100%)",
"linear-gradient(135deg, #1A202C 0%, #1D4044 30%, #234E52 60%, #285E61 100%)",
);
const heroTextColor = useColorModeValue("gray.800", "white");
const statsBg = useColorModeValue(
"rgba(255, 255, 255, 0.6)",
"rgba(26, 32, 44, 0.6)",
);
const statsBorder = useColorModeValue(
"rgba(255, 255, 255, 0.8)",
"rgba(255, 255, 255, 0.06)",
);
return (
<Box className="gradient-mesh" position="relative">
{/* Hero Section */}
<MotionBox
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: [0.25, 0.1, 0.25, 1] }}
>
<Box
bgGradient={heroBg}
borderRadius="3xl"
px={{ base: 6, md: 12 }}
py={{ base: 14, md: 24 }}
mb={12}
position="relative"
overflow="hidden"
>
{/* Animated gradient orbs */}
<GradientOrb
color="rgba(56, 178, 172, 0.2)"
size={250}
top="-80px"
right="-60px"
blur={80}
/>
<GradientOrb
color="rgba(128, 90, 213, 0.15)"
size={200}
bottom="-60px"
left="-40px"
blur={70}
/>
<GradientOrb
color="rgba(66, 153, 225, 0.1)"
size={150}
top="50%"
right="20%"
blur={50}
/>
{/* Sparkle particles */}
<Sparkles count={8} color="rgba(255, 255, 255, 0.4)" />
{/* Decorative grid pattern */}
<Box
position="absolute"
inset={0}
opacity={0.03}
backgroundImage="radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)"
backgroundSize="40px 40px"
pointerEvents="none"
/>
<VStack
gap={6}
position="relative"
zIndex={1}
maxW="2xl"
mx="auto"
textAlign="center"
>
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }}
>
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl", lg: "6xl" }}
fontWeight="800"
color={heroTextColor}
lineHeight="shorter"
letterSpacing="tight"
>
{t("hero-title")}
</Heading>
</MotionBox>
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
<Text
fontSize={{ base: "md", md: "lg" }}
color={heroTextColor}
opacity={0.85}
maxW="lg"
lineHeight="tall"
>
{t("hero-subtitle")}
</Text>
</MotionBox>
<MotionBox
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.6 }}
>
<HStack gap={4} mt={2}>
<Button
size="lg"
colorPalette="primary"
borderRadius="full"
px={8}
fontWeight="bold"
onClick={() => router.push("/matches")}
_hover={{ transform: "scale(1.05)", shadow: "xl" }}
transition="all 0.3s"
className="animate-glow"
>
{t("get-started")}
</Button>
<Button
size="lg"
variant="outline"
borderRadius="full"
px={8}
color={heroTextColor}
borderColor={heroTextColor}
_hover={{
bg: "whiteAlpha.200",
transform: "scale(1.03)",
}}
transition="all 0.3s"
>
{t("learn-more")}
</Button>
</HStack>
</MotionBox>
</VStack>
</Box>
</MotionBox>
{/* Stats Section — glassmorphic card */}
<ScrollSlideUp>
<Box
bg={statsBg}
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor={statsBorder}
borderRadius="2xl"
px={{ base: 4, md: 8 }}
py={{ base: 6, md: 8 }}
mb={16}
shadow="lg"
>
<SimpleGrid columns={{ base: 2, md: 4 }} gap={6}>
<StatBlock value={15000} label={t("stats-predictions")} suffix="+" />
<StatBlock value={72} label={t("stats-accuracy")} suffix="%" />
<StatBlock value={3200} label={t("stats-users")} suffix="+" />
<StatBlock value={50000} label={t("stats-matches")} suffix="+" />
</SimpleGrid>
</Box>
</ScrollSlideUp>
{/* Features Section */}
<Box mb={16}>
<ScrollScaleIn>
<Heading as="h2" size="xl" textAlign="center" mb={3} fontWeight="bold">
{t("features-title")}
</Heading>
<Text
textAlign="center"
color="fg.muted"
fontSize="md"
maxW="lg"
mx="auto"
mb={10}
>
{t("hero-subtitle")}
</Text>
</ScrollScaleIn>
<StaggerContainer inView>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} gap={6}>
<StaggerItem>
<FeatureCard
icon={<LuBrain />}
title={t("feature-ai")}
description={t("feature-ai-desc")}
colorPalette="primary"
/>
</StaggerItem>
<StaggerItem>
<FeatureCard
icon={<LuTrendingUp />}
title={t("feature-value")}
description={t("feature-value-desc")}
colorPalette="green"
/>
</StaggerItem>
<StaggerItem>
<FeatureCard
icon={<LuTicket />}
title={t("feature-coupon")}
description={t("feature-coupon-desc")}
colorPalette="purple"
/>
</StaggerItem>
<StaggerItem>
<FeatureCard
icon={<LuRadio />}
title={t("feature-live")}
description={t("feature-live-desc")}
colorPalette="red"
/>
</StaggerItem>
</SimpleGrid>
</StaggerContainer>
</Box>
</Box>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as HomeContent } from "./home-content";
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { Box, Text, HStack, Flex, Link as ChakraLink } from "@chakra-ui/react";
import { Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
export default function Footer() {
const t = useTranslations();
return (
<Box as="footer" bg="bg.muted" mt="auto">
<Flex
justify={{ base: "center", md: "space-between" }}
align="center"
maxW="8xl"
mx="auto"
wrap="wrap"
px={{ base: 4, md: 8 }}
py={4}
gap={3}
>
<Text fontSize="sm" color="fg.muted">
© {new Date().getFullYear()}{" "}
<ChakraLink
href="/"
color={{ base: "primary.600", _dark: "primary.300" }}
focusRing="none"
fontWeight="semibold"
>
Suggest Bet
</ChakraLink>
. {t("all-right-reserved")}
</Text>
<HStack spaceX={4}>
<ChakraLink
as={Link}
href="/privacy-and-security-policy"
fontSize="sm"
color="fg.muted"
focusRing="none"
textDecor="none"
transition="color 0.2s"
_hover={{
color: { base: "primary.500", _dark: "primary.300" },
}}
>
{t("privacy-policy")}
</ChakraLink>
<ChakraLink
as={Link}
href="/terms-of-use"
fontSize="sm"
color="fg.muted"
focusRing="none"
textDecor="none"
transition="color 0.2s"
_hover={{
color: { base: "primary.500", _dark: "primary.300" },
}}
>
{t("terms-of-service")}
</ChakraLink>
</HStack>
</Flex>
</Box>
);
}
@@ -0,0 +1,120 @@
import { Box, Link as ChakraLink, Text } from "@chakra-ui/react";
import { NavItem } from "@/config/navigation";
import {
MenuContent,
MenuItem,
MenuRoot,
MenuTrigger,
} from "@/components/ui/overlays/menu";
import { RxChevronDown } from "react-icons/rx";
import { useActiveNavItem } from "@/hooks/useActiveNavItem";
import { Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useRef, useState } from "react";
function HeaderLink({ item }: { item: NavItem }) {
const t = useTranslations("nav");
const { isActive, isChildActive } = useActiveNavItem(item);
const [open, setOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseOpen = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(true);
};
const handleMouseClose = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), 150);
};
const activeBg = { base: "primary.50", _dark: "primary.950" };
const activeColor = { base: "primary.600", _dark: "primary.300" };
const hoverBg = { base: "gray.50", _dark: "gray.800" };
return (
<Box key={item.label}>
{item.children ? (
<Box onMouseEnter={handleMouseOpen} onMouseLeave={handleMouseClose}>
<MenuRoot open={open} onOpenChange={(e) => setOpen(e.open)}>
<MenuTrigger asChild>
<Text
display="inline-flex"
alignItems="center"
gap="1"
cursor="pointer"
color={isActive ? activeColor : "fg.muted"}
bg={isActive ? activeBg : "transparent"}
fontWeight={isActive ? "bold" : "medium"}
fontSize="sm"
px="3"
py="1.5"
borderRadius="lg"
transition="all 0.2s ease"
_hover={{
color: activeColor,
bg: isActive ? activeBg : hoverBg,
}}
>
{t(item.label)}
<RxChevronDown
style={{
transform: open ? "rotate(-180deg)" : "rotate(0deg)",
transition: "transform 0.2s",
fontSize: "12px",
}}
/>
</Text>
</MenuTrigger>
<MenuContent>
{item.children.map((child) => {
const isActiveChild = isChildActive(child.href);
return (
<MenuItem key={child.href} value={child.href}>
<ChakraLink
as={Link}
href={child.href}
focusRing="none"
w="full"
color={isActiveChild ? activeColor : "fg.muted"}
textDecor="none"
fontWeight={isActiveChild ? "bold" : "medium"}
fontSize="sm"
_hover={{ color: activeColor }}
>
{t(child.label)}
</ChakraLink>
</MenuItem>
);
})}
</MenuContent>
</MenuRoot>
</Box>
) : (
<ChakraLink
as={Link}
href={item.href}
focusRing="none"
color={isActive ? activeColor : "fg.muted"}
bg={isActive ? activeBg : "transparent"}
textDecor="none"
fontWeight={isActive ? "bold" : "medium"}
fontSize="sm"
px="3"
py="1.5"
borderRadius="lg"
transition="all 0.2s ease"
_hover={{
color: activeColor,
bg: isActive ? activeBg : hoverBg,
textDecor: "none",
}}
>
{t(item.label)}
</ChakraLink>
)}
</Box>
);
}
export default HeaderLink;
+302
View File
@@ -0,0 +1,302 @@
"use client";
import {
Box,
Flex,
HStack,
IconButton,
Link as ChakraLink,
Stack,
VStack,
Button,
MenuItem,
ClientOnly,
Text,
Separator,
} from "@chakra-ui/react";
import { Link, useRouter } from "@/i18n/navigation";
import { ColorModeButton } from "@/components/ui/color-mode";
import {
PopoverBody,
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from "@/components/ui/overlays/popover";
import { RxHamburgerMenu } from "react-icons/rx";
import { NAV_ITEMS, getVisibleNavItems } from "@/config/navigation";
import HeaderLink from "./header-link";
import MobileHeaderLink from "./mobile-header-link";
import LocaleSwitcher from "@/components/ui/locale-switcher";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
MenuContent,
MenuRoot,
MenuTrigger,
} from "@/components/ui/overlays/menu";
import { Avatar } from "@/components/ui/data-display/avatar";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signOut, useSession } from "next-auth/react";
import { authConfig } from "@/config/auth";
import { LoginModal } from "@/components/auth/login-modal";
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
import GlobalSearch from "@/components/search/global-search";
export default function Header() {
const t = useTranslations();
const [isSticky, setIsSticky] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false);
const router = useRouter();
const { data: session, status } = useSession();
const isAuthenticated = !!session;
const isLoading = status === "loading";
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
useEffect(() => {
const handleScroll = () => setIsSticky(window.scrollY >= 10);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleLogout = async () => {
await signOut({ redirect: false });
if (authConfig.isAuthRequired) {
router.replace("/signin");
}
};
// Desktop auth section
const renderAuthSection = () => {
if (isLoading) return <Skeleton boxSize="10" rounded="full" />;
if (isAuthenticated) {
return (
<MenuRoot positioning={{ placement: "bottom-start" }}>
<MenuTrigger rounded="full" focusRing="none">
<Avatar name={session?.user?.name || "User"} variant="solid" />
</MenuTrigger>
<MenuContent>
<MenuItem value="profile" onClick={() => router.push("/profile")}>
<LuUser />
{t("nav.profile")}
</MenuItem>
{session?.user &&
session.user.roles?.includes("ADMIN") && (
<MenuItem value="admin" onClick={() => router.push("/admin")}>
<LuShield />
{t("nav.admin")}
</MenuItem>
)}
<MenuItem onClick={handleLogout} value="sign-out">
{t("auth.sign-out")}
</MenuItem>
</MenuContent>
</MenuRoot>
);
}
return (
<Button
variant="solid"
colorPalette="primary"
size="sm"
borderRadius="full"
onClick={() => setLoginModalOpen(true)}
>
<LuLogIn />
{t("auth.sign-in")}
</Button>
);
};
// Mobile auth section
const renderMobileAuthSection = () => {
if (isLoading) return <Skeleton height="10" width="full" />;
if (isAuthenticated) {
return (
<VStack gap={2} w="full">
<Flex align="center" gap={2} w="full">
<Avatar
name={session?.user?.name || "User"}
variant="solid"
size="sm"
/>
<Text fontSize="sm" fontWeight="semibold" truncate>
{session?.user?.name || session?.user?.email}
</Text>
</Flex>
<Button
variant="ghost"
size="sm"
width="full"
justifyContent="flex-start"
onClick={() => router.push("/profile")}
>
<LuUser />
{t("nav.profile")}
</Button>
<Button
variant="surface"
size="sm"
width="full"
onClick={handleLogout}
>
{t("auth.sign-out")}
</Button>
</VStack>
);
}
return (
<Button
variant="solid"
colorPalette="primary"
size="sm"
width="full"
borderRadius="full"
onClick={() => setLoginModalOpen(true)}
>
<LuLogIn />
{t("auth.sign-in")}
</Button>
);
};
return (
<>
<Box
as="nav"
bg={isSticky ? "rgba(255, 255, 255, 0.75)" : "white"}
_dark={{
bg: isSticky ? "rgba(1, 1, 1, 0.75)" : "black",
}}
shadow={isSticky ? "md" : "xs"}
backdropFilter="blur(16px) saturate(180%)"
borderBottom="1px solid"
borderColor={{ base: "gray.100", _dark: "gray.800" }}
transition="all 0.3s ease-in-out"
px={{ base: 4, md: 6 }}
py="2.5"
position="sticky"
top={0}
zIndex={10}
w="full"
>
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
{/* Logo */}
<ChakraLink
as={Link}
href="/home"
focusRing="none"
textDecor="none"
_hover={{ textDecor: "none" }}
display="flex"
alignItems="center"
gap="2"
flexShrink={0}
mr={6}
>
<Flex
boxSize="32px"
bg="primary.500"
borderRadius="lg"
align="center"
justify="center"
shadow="sm"
>
<LuZap color="white" size={18} />
</Flex>
<Box>
<Text
fontSize="md"
fontWeight="800"
lineHeight="1"
color={{ base: "gray.900", _dark: "white" }}
letterSpacing="-0.02em"
>
Suggest
</Text>
<Text
fontSize="xs"
fontWeight="600"
lineHeight="1"
mt="1px"
color={{ base: "primary.600", _dark: "primary.300" }}
letterSpacing="0.08em"
textTransform="uppercase"
>
BET
</Text>
</Box>
</ChakraLink>
{/* DESKTOP NAVIGATION */}
<HStack gap={1} display={{ base: "none", lg: "flex" }} flex={1}>
{visibleItems.map((item) => (
<HeaderLink key={item.href} item={item} />
))}
</HStack>
{/* Right side actions */}
<HStack gap={2} flexShrink={0}>
{/* Global Search (Desktop) */}
<Box display={{ base: "none", lg: "block" }}>
<GlobalSearch />
</Box>
<Separator
orientation="vertical"
height="5"
display={{ base: "none", lg: "block" }}
borderColor={{ base: "gray.200", _dark: "gray.700" }}
/>
<ColorModeButton colorPalette="gray" />
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
<LocaleSwitcher />
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
{renderAuthSection()}
</ClientOnly>
</Box>
{/* MOBILE NAVIGATION */}
<Stack display={{ base: "inline-flex", lg: "none" }}>
<ClientOnly fallback={<Skeleton boxSize="9" />}>
<PopoverRoot>
<PopoverTrigger as="span">
<IconButton aria-label="Open menu" variant="ghost">
<RxHamburgerMenu />
</IconButton>
</PopoverTrigger>
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
<PopoverBody>
<VStack mt="2" align="start" spaceY="2" w="full">
{visibleItems.map((item) => (
<MobileHeaderLink key={item.href} item={item} />
))}
<Box
w="full"
pt={2}
borderTopWidth="1px"
borderColor="border.muted"
>
<LocaleSwitcher />
</Box>
{renderMobileAuthSection()}
</VStack>
</PopoverBody>
</PopoverContent>
</PopoverRoot>
</ClientOnly>
</Stack>
</HStack>
</Flex>
</Box>
{/* Login Modal */}
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
</>
);
}
@@ -0,0 +1,117 @@
import {
Text,
Box,
Link as ChakraLink,
useDisclosure,
VStack,
} from "@chakra-ui/react";
import { RxChevronDown } from "react-icons/rx";
import { NavItem } from "@/config/navigation";
import { useActiveNavItem } from "@/hooks/useActiveNavItem";
import { Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
function MobileHeaderLink({ item }: { item: NavItem }) {
const t = useTranslations("nav");
const { isActive, isChildActive } = useActiveNavItem(item);
const { open, onToggle } = useDisclosure();
const activeColor = { base: "primary.600", _dark: "primary.300" };
const activeBg = { base: "primary.50", _dark: "primary.950" };
return (
<Box key={item.label} w="full">
{item.children ? (
<VStack align="start" w="full" spaceY={0}>
<Text
onClick={onToggle}
display="inline-flex"
alignItems="center"
gap="1"
cursor="pointer"
color={isActive ? activeColor : "fg.muted"}
bg={isActive ? activeBg : "transparent"}
fontWeight={isActive ? "bold" : "semibold"}
fontSize="sm"
px="3"
py="2"
w="full"
borderRadius="lg"
transition="all 0.2s ease"
_hover={{
color: activeColor,
bg: activeBg,
}}
>
{t(item.label)}
<RxChevronDown
style={{
transform: open ? "rotate(-180deg)" : "rotate(0deg)",
transition: "transform 0.2s",
marginLeft: "auto",
}}
/>
</Text>
{open && item.children && (
<VStack align="start" pl="3" pt="1" pb="1" w="full" spaceY={0}>
{item.children.map((child) => {
const isActiveChild = isChildActive(child.href);
return (
<ChakraLink
key={child.href}
as={Link}
href={child.href}
focusRing="none"
color={isActiveChild ? activeColor : "fg.muted"}
bg={isActiveChild ? activeBg : "transparent"}
textDecor="none"
fontWeight={isActiveChild ? "bold" : "medium"}
fontSize="sm"
px="3"
py="1.5"
w="full"
borderRadius="md"
transition="all 0.2s ease"
_hover={{
color: activeColor,
bg: activeBg,
textDecor: "none",
}}
>
{t(child.label)}
</ChakraLink>
);
})}
</VStack>
)}
</VStack>
) : (
<ChakraLink
as={Link}
href={item.href}
w="full"
focusRing="none"
color={isActive ? activeColor : "fg.muted"}
bg={isActive ? activeBg : "transparent"}
textDecor="none"
fontWeight={isActive ? "bold" : "semibold"}
fontSize="sm"
px="3"
py="2"
borderRadius="lg"
display="block"
transition="all 0.2s ease"
_hover={{
color: activeColor,
bg: activeBg,
textDecor: "none",
}}
>
{t(item.label)}
</ChakraLink>
)}
</Box>
);
}
export default MobileHeaderLink;
+335
View File
@@ -0,0 +1,335 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Card,
VStack,
HStack,
Badge,
Spinner,
Input,
Tabs,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import {
useCountries,
useLeagues,
useSearchTeams,
} from "@/lib/api/leagues/use-hooks";
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
import { LuSearch, LuGlobe, LuTrophy, LuUsers } from "react-icons/lu";
import { useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { Link } from "@/i18n/navigation";
import { InputGroup } from "@/components/ui/forms/input-group";
import { Link as ChakraLink } from "@chakra-ui/react";
export default function LeaguesContent() {
const t = useTranslations("leagues");
const tMatches = useTranslations("matches");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
const [sportFilter, setSportFilter] = useState<string>("");
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebounce(searchQuery, 300);
const countries = useCountries();
const leagues = useLeagues(
sportFilter
? { sport: sportFilter as "football" | "basketball" }
: undefined,
);
const searchTeams = useSearchTeams(
debouncedQuery.length >= 2 ? { q: debouncedQuery } : { q: "" },
);
return (
<SlideUp>
<Box maxW="6xl" mx="auto">
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
{t("title")}
</Heading>
<Tabs.Root
value={activeTab}
onValueChange={(e) => setActiveTab(e.value as "leagues" | "teams")}
>
<Tabs.List>
<Tabs.Trigger value="leagues">
<LuGlobe />
{t("countries-leagues")}
</Tabs.Trigger>
<Tabs.Trigger value="teams">
<LuUsers />
{tMatches("search-teams")}
</Tabs.Trigger>
</Tabs.List>
{/* Countries & Leagues Tab */}
<Tabs.Content value="leagues">
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
{/* Countries Sidebar */}
<Box w={{ base: "full", lg: "280px" }} flexShrink={0}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Header>
<Heading as="h4" size="sm">
<HStack gap={2}>
<LuGlobe />
<Text>{t("countries")}</Text>
</HStack>
</Heading>
</Card.Header>
<Card.Body pt={0} maxH="600px" overflowY="auto">
{countries.isLoading ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
</Flex>
) : (
<VStack gap={1} align="stretch">
{countries.data?.data?.map((country: CountryDto) => (
<Flex
key={country.id}
px={3}
py={2}
borderRadius="md"
_hover={{
bg: "gray.50",
_dark: { bg: "gray.750" },
}}
cursor="pointer"
justify="space-between"
align="center"
>
<HStack gap={2}>
{country.flag ? (
<img
src={country.flag}
width="16"
height="16"
style={{ borderRadius: "2px" }}
alt={country.name}
/>
) : null}
<Text fontSize="sm">{country.name}</Text>
</HStack>
<Badge size="xs" colorScheme="gray">
{country.leagues?.length || 0}
</Badge>
</Flex>
))}
</VStack>
)}
</Card.Body>
</Card.Root>
</Box>
{/* Leagues List */}
<Box flex={1}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Header>
<Flex justify="space-between" align="center">
<Heading as="h4" size="sm">
<HStack gap={2}>
<LuTrophy />
<Text>{t("leagues")}</Text>
</HStack>
</Heading>
<HStack gap={2}>
<Badge
cursor="pointer"
colorScheme={!sportFilter ? "primary" : "gray"}
onClick={() => setSportFilter("")}
>
{tMatches("all")}
</Badge>
<Badge
cursor="pointer"
colorScheme={
sportFilter === "football" ? "green" : "gray"
}
onClick={() =>
setSportFilter(
sportFilter === "football" ? "" : "football",
)
}
>
{tMatches("football")}
</Badge>
<Badge
cursor="pointer"
colorScheme={
sportFilter === "basketball" ? "orange" : "gray"
}
onClick={() =>
setSportFilter(
sportFilter === "basketball" ? "" : "basketball",
)
}
>
{tMatches("basketball")}
</Badge>
</HStack>
</Flex>
</Card.Header>
<Card.Body pt={0}>
{leagues.isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="sm" />
</Flex>
) : (
<VStack gap={2}>
{leagues.data?.data?.map((league: LeagueDto) => (
<ChakraLink
key={league.id}
as={Link}
href="/matches"
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
_hover={{
borderColor: "primary.300",
bg: "primary.50",
_dark: { bg: "gray.750" },
}}
display="flex"
justifyContent="space-between"
alignItems="center"
textDecoration="none"
color="inherit"
>
<VStack align="start" gap={0}>
<Text fontWeight="semibold">{league.name}</Text>
<Text fontSize="xs" color="fg.muted">
{league.country?.name || ""}
</Text>
</VStack>
<HStack gap={2}>
{league.sport ? (
<Badge
size="xs"
colorScheme={
league.sport === "football"
? "green"
: "orange"
}
>
{league.sport}
</Badge>
) : null}
{league.season ? (
<Text fontSize="xs" color="fg.muted">
{league.season}
</Text>
) : null}
</HStack>
</ChakraLink>
))}
</VStack>
)}
</Card.Body>
</Card.Root>
</Box>
</Flex>
</Tabs.Content>
{/* Teams Search Tab */}
<Tabs.Content value="teams">
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<InputGroup startElement={<LuSearch />} mb={4}>
<Input
placeholder={tMatches("search-teams")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
{debouncedQuery.length < 2 ? (
<Text color="fg.muted" textAlign="center" py={8}>
{t("search-at-least-2")}
</Text>
) : searchTeams.isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="md" />
</Flex>
) : (
<VStack gap={2}>
{searchTeams.data?.data?.map((team: TeamDto) => (
<ChakraLink
key={team.id}
as={Link}
href={`/teams/${team.id}`}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
_hover={{
borderColor: "primary.300",
bg: "primary.50",
_dark: { bg: "gray.750" },
}}
display="flex"
alignItems="center"
gap={3}
textDecoration="none"
color="inherit"
>
{team.logo ? (
<img
src={team.logo}
width="32"
height="32"
style={{ borderRadius: "50%" }}
alt={team.name}
/>
) : (
<Box
boxSize="32px"
borderRadius="full"
bg="gray.200"
_dark={{ bg: "gray.600" }}
/>
)}
<VStack align="start" gap={0}>
<Text fontWeight="semibold">{team.name}</Text>
<Text fontSize="xs" color="fg.muted">
{team.country || ""}
</Text>
</VStack>
<Badge
ml="auto"
size="xs"
colorScheme={
team.sport === "football" ? "green" : "orange"
}
>
{team.sport}
</Badge>
</ChakraLink>
))}
</VStack>
)}
</Card.Body>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Box>
</SlideUp>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { default as MatchCard } from "./match-card";
export { default as MatchList } from "./match-list";
export { default as SportFilter } from "./sport-filter";
export { default as LeagueSidebar } from "./league-sidebar";
export { default as PredictionCard } from "./prediction-card";
export { default as MatchDetailContent } from "./match-detail-content";
export { default as MatchesContent } from "./matches-content";
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { Box, VStack, Text, Badge, Flex, Image } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import type { ActiveLeagueDto } from "@/lib/api/matches/types";
interface LeagueSidebarProps {
leagues: ActiveLeagueDto[];
selectedLeagueId: string | null;
onSelect: (leagueId: string | null) => void;
isLoading?: boolean;
}
export default function LeagueSidebar({
leagues,
selectedLeagueId,
onSelect,
isLoading,
}: LeagueSidebarProps) {
const t = useTranslations("matches");
const bg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const activeBg = useColorModeValue("primary.50", "primary.900");
const hoverBg = useColorModeValue("gray.50", "gray.750");
if (isLoading) {
return (
<Box
bg={bg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={4}
>
<VStack gap={3}>
{Array.from({ length: 6 }).map((_, i) => (
<Box
key={i}
h="40px"
w="100%"
bg="bg.muted"
borderRadius="lg"
animation="pulse 1.5s ease-in-out infinite"
/>
))}
</VStack>
</Box>
);
}
return (
<Box
bg={bg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
>
{/* Header */}
<Box px={4} py={3} borderBottomWidth="1px" borderColor={borderColor}>
<Text
fontSize="sm"
fontWeight="bold"
textTransform="uppercase"
letterSpacing="wide"
color="fg.muted"
>
{t("active-leagues")}
</Text>
</Box>
{/* All Leagues Option */}
<Box
px={4}
py={2.5}
cursor="pointer"
bg={selectedLeagueId === null ? activeBg : "transparent"}
_hover={{ bg: selectedLeagueId === null ? activeBg : hoverBg }}
onClick={() => onSelect(null)}
transition="background 0.15s"
borderBottomWidth="1px"
borderColor={borderColor}
>
<Text
fontSize="sm"
fontWeight={selectedLeagueId === null ? "bold" : "medium"}
color={selectedLeagueId === null ? "primary.fg" : "fg"}
>
{t("all-leagues")}
</Text>
</Box>
{/* League List */}
<VStack gap={0} align="stretch" maxH="60vh" overflowY="auto">
{leagues.map((league) => {
const isActive = selectedLeagueId === league.id;
return (
<Box
key={league.id}
px={4}
py={2.5}
cursor="pointer"
bg={isActive ? activeBg : "transparent"}
_hover={{ bg: isActive ? activeBg : hoverBg }}
onClick={() => onSelect(league.id)}
transition="background 0.15s"
borderBottomWidth="1px"
borderColor={borderColor}
>
<Flex justify="space-between" align="center">
<Flex align="center" gap={2} minW={0} flex={1}>
{league.countryFlag && (
<Image
src={league.countryFlag}
alt={league.countryName || ""}
boxSize="16px"
objectFit="contain"
flexShrink={0}
/>
)}
<Text
fontSize="sm"
fontWeight={isActive ? "bold" : "medium"}
color={isActive ? "primary.fg" : "fg"}
truncate
>
{league.name}
</Text>
</Flex>
<Flex gap={1.5} flexShrink={0}>
{league.liveCount > 0 && (
<Badge
colorPalette="red"
variant="solid"
borderRadius="full"
fontSize="2xs"
px={1.5}
>
{league.liveCount}
</Badge>
)}
<Badge
colorPalette="gray"
variant="subtle"
borderRadius="full"
fontSize="2xs"
px={1.5}
>
{league.matchCount}
</Badge>
</Flex>
</Flex>
</Box>
);
})}
</VStack>
</Box>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import {
Box,
Flex,
Text,
Badge,
HStack,
VStack,
Image,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { slideUpVariants } from "@/components/motion";
import type { MatchResponseDto } from "@/lib/api/matches/types";
import { useColorModeValue } from "@/components/ui/color-mode";
interface MatchCardProps {
match: MatchResponseDto;
}
const MotionBox = motion.create(Box);
export default function MatchCard({ match }: MatchCardProps) {
const t = useTranslations("matches");
const router = useRouter();
const cardBg = useColorModeValue("white", "gray.800");
const cardBorder = useColorModeValue("gray.100", "gray.700");
const hoverBg = useColorModeValue("gray.50", "gray.750");
const hoverBorder = useColorModeValue("primary.200", "primary.500");
const isLive = match.status === "LIVE";
const isFinished = match.status === "Finished";
const statusColor = isLive ? "red" : isFinished ? "gray" : "green";
const statusText = isLive
? t("live")
: isFinished
? t("finished")
: t("not-started");
const handleClick = () => {
router.push(`/matches/${match.id}`);
};
// Date handling from timestamp (mstUtc)
const matchDate = new Date(match.mstUtc);
return (
<MotionBox
variants={slideUpVariants}
bg={cardBg}
borderWidth="1px"
borderColor={cardBorder}
borderRadius="xl"
p={4}
cursor="pointer"
onClick={handleClick}
transition={{ duration: 0.25 }}
_hover={{
bg: hoverBg,
borderColor: hoverBorder,
transform: "translateY(-3px)",
shadow: "xl",
}}
role="button"
tabIndex={0}
aria-label={`${match.homeTeamName} vs ${match.awayTeamName}`}
>
{/* Status Badge */}
<Flex justify="space-between" align="center" mb={3}>
<Badge
colorPalette={statusColor}
variant="subtle"
px={2}
py={0.5}
borderRadius="full"
fontSize="xs"
fontWeight="bold"
>
{isLive && (
<Box
as="span"
display="inline-block"
w="6px"
h="6px"
borderRadius="full"
bg="red.500"
mr={1.5}
animation="pulse 1.5s ease-in-out infinite"
/>
)}
{statusText}
</Badge>
<Text fontSize="xs" color="fg.muted">
{matchDate.toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</Flex>
{/* Teams */}
<HStack gap={3} justify="space-between">
{/* Home Team */}
<VStack gap={1} flex={1} align="center" minW={0}>
{match.homeTeamLogo ? (
<Image
src={match.homeTeamLogo}
alt={match.homeTeamName}
boxSize="40px"
objectFit="contain"
/>
) : (
<Flex
boxSize="40px"
bg="primary.subtle"
borderRadius="full"
align="center"
justify="center"
>
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
{match.homeTeamName?.charAt(0) || "H"}
</Text>
</Flex>
)}
<Text
fontSize="sm"
fontWeight="semibold"
textAlign="center"
truncate
maxW="100%"
>
{match.homeTeamName}
</Text>
</VStack>
{/* Score or VS */}
<VStack gap={0} flexShrink={0}>
{(isLive || isFinished) &&
match.scoreHome !== undefined &&
match.scoreAway !== undefined ? (
<HStack gap={2}>
<Text
fontSize="2xl"
fontWeight="900"
color={isLive ? "red.500" : "fg"}
>
{match.scoreHome}
</Text>
<Text fontSize="lg" color="fg.muted">
-
</Text>
<Text
fontSize="2xl"
fontWeight="900"
color={isLive ? "red.500" : "fg"}
>
{match.scoreAway}
</Text>
</HStack>
) : (
<Text fontSize="md" fontWeight="bold" color="fg.muted">
{t("vs")}
</Text>
)}
</VStack>
{/* Away Team */}
<VStack gap={1} flex={1} align="center" minW={0}>
{match.awayTeamLogo ? (
<Image
src={match.awayTeamLogo}
alt={match.awayTeamName}
boxSize="40px"
objectFit="contain"
/>
) : (
<Flex
boxSize="40px"
bg="primary.subtle"
borderRadius="full"
align="center"
justify="center"
>
<Text fontSize="lg" fontWeight="bold" color="primary.fg">
{match.awayTeamName?.charAt(0) || "A"}
</Text>
</Flex>
)}
<Text
fontSize="sm"
fontWeight="semibold"
textAlign="center"
truncate
maxW="100%"
>
{match.awayTeamName}
</Text>
</VStack>
</HStack>
{/* League Info */}
{(match.leagueName || match.countryName) && (
<Flex
mt={3}
pt={2}
borderTopWidth="1px"
borderColor={cardBorder}
justify="center"
align="center"
gap={1.5}
>
{/* Flag handling if available in flat response, otherwise skip or pass from parent */}
<Text fontSize="xs" color="fg.muted" truncate>
{match.countryName && `${match.countryName}`}
{match.leagueName}
</Text>
</Flex>
)}
</MotionBox>
);
}
@@ -0,0 +1,281 @@
"use client";
import {
Box,
Flex,
Text,
Heading,
Badge,
VStack,
HStack,
Image,
Spinner,
Button,
Card,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import { useMatchDetails } from "@/lib/api/matches/use-hooks";
import { usePrediction } from "@/lib/api/predictions/use-hooks";
import PredictionCard from "@/components/matches/prediction-card";
import OddsCard from "@/components/matches/odds-card";
import { LuArrowLeft, LuRefreshCw } from "react-icons/lu";
export default function MatchDetailContent() {
const t = useTranslations("matches");
const tPred = useTranslations("predictions");
const tCommon = useTranslations("common");
const params = useParams();
const router = useRouter();
const matchId = params.id as string;
const { data: matchData, isLoading: matchLoading } = useMatchDetails(matchId);
const {
data: predictionData,
isLoading: predLoading,
refetch: refetchPrediction,
} = usePrediction(matchId);
const headerBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const match = matchData?.data;
const prediction = predictionData?.data;
if (matchLoading) {
return (
<Flex justify="center" align="center" py={20}>
<Spinner size="lg" color="primary.500" />
</Flex>
);
}
if (!match) {
return (
<Flex justify="center" align="center" py={20} direction="column" gap={4}>
<Text color="fg.muted" fontSize="lg">
{t("no-matches")}
</Text>
<Button variant="outline" onClick={() => router.back()}>
<LuArrowLeft />
{tCommon("back")}
</Button>
</Flex>
);
}
const isLive = match.status === "LIVE";
const isFinished = match.status === "Finished";
return (
<SlideUp>
<Box>
{/* Back Button */}
<Button
variant="ghost"
size="sm"
mb={4}
onClick={() => router.back()}
gap={1.5}
>
<LuArrowLeft />
{tCommon("back")}
</Button>
{/* Match Header */}
<Card.Root
bg={headerBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Body>
{/* League Info */}
{match.league && (
<Flex justify="center" align="center" gap={2} mb={4}>
{match.league.country?.flag && (
<Image
src={match.league.country.flag}
alt={match.league.country.name || ""}
boxSize="18px"
objectFit="contain"
/>
)}
<Text fontSize="sm" color="fg.muted" fontWeight="medium">
{match.league.name}
</Text>
<Badge
colorPalette={isLive ? "red" : isFinished ? "gray" : "green"}
variant="subtle"
fontSize="xs"
borderRadius="full"
>
{isLive && (
<Box
as="span"
display="inline-block"
w="6px"
h="6px"
borderRadius="full"
bg="red.500"
mr={1}
animation="pulse 1.5s ease-in-out infinite"
/>
)}
{isLive
? t("live")
: isFinished
? t("finished")
: t("not-started")}
</Badge>
</Flex>
)}
{/* Teams & Score */}
<HStack gap={6} justify="center" align="center">
{/* Home Team */}
<VStack gap={2} flex={1} align="center">
{match.homeTeam?.logo ? (
<Image
src={match.homeTeam.logo}
alt={match.homeTeam.name}
boxSize="64px"
objectFit="contain"
/>
) : (
<Flex
boxSize="64px"
bg="primary.subtle"
borderRadius="full"
align="center"
justify="center"
>
<Text fontSize="2xl" fontWeight="bold" color="primary.fg">
{match.homeTeam?.name?.charAt(0) || "H"}
</Text>
</Flex>
)}
<Text fontSize="md" fontWeight="bold" textAlign="center">
{match.homeTeam?.name}
</Text>
<Text fontSize="xs" color="fg.muted">
{t("home-team")}
</Text>
</VStack>
{/* Score */}
<VStack gap={1} flexShrink={0}>
{match.score && (isLive || isFinished) ? (
<HStack gap={3}>
<Text
fontSize="4xl"
fontWeight="900"
color={isLive ? "red.500" : "fg"}
>
{match.score.home}
</Text>
<Text fontSize="2xl" color="fg.muted">
-
</Text>
<Text
fontSize="4xl"
fontWeight="900"
color={isLive ? "red.500" : "fg"}
>
{match.score.away}
</Text>
</HStack>
) : (
<Text fontSize="xl" fontWeight="bold" color="fg.muted">
{t("vs")}
</Text>
)}
<Text fontSize="xs" color="fg.muted">
{new Date(match.mstUtc).toLocaleDateString("tr-TR", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</VStack>
{/* Away Team */}
<VStack gap={2} flex={1} align="center">
{match.awayTeam?.logo ? (
<Image
src={match.awayTeam.logo}
alt={match.awayTeam.name}
boxSize="64px"
objectFit="contain"
/>
) : (
<Flex
boxSize="64px"
bg="primary.subtle"
borderRadius="full"
align="center"
justify="center"
>
<Text fontSize="2xl" fontWeight="bold" color="primary.fg">
{match.awayTeam?.name?.charAt(0) || "A"}
</Text>
</Flex>
)}
<Text fontSize="md" fontWeight="bold" textAlign="center">
{match.awayTeam?.name}
</Text>
<Text fontSize="xs" color="fg.muted">
{t("away-team")}
</Text>
</VStack>
</HStack>
</Card.Body>
</Card.Root>
{/* Prediction Section */}
<Box>
<Flex justify="space-between" align="center" mb={4}>
<Heading as="h2" size="lg">
{tPred("title")}
</Heading>
<Button
variant="outline"
size="sm"
onClick={() => refetchPrediction()}
gap={1.5}
>
<LuRefreshCw />
{tCommon("refresh")}
</Button>
</Flex>
{predLoading ? (
<Flex justify="center" py={10}>
<Spinner size="md" color="primary.500" />
</Flex>
) : prediction ? (
<PredictionCard prediction={prediction} />
) : (
<Card.Root borderColor={borderColor} borderRadius="xl">
<Card.Body>
<Flex justify="center" align="center" py={8}>
<Text color="fg.muted">{tPred("no-predictions")}</Text>
</Flex>
</Card.Body>
</Card.Root>
)}
</Box>
{/* Odds Section */}
{match.odds && Object.keys(match.odds).length > 0 && (
<OddsCard odds={match.odds} />
)}
</Box>
</SlideUp>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import { Box, Grid, Text, Flex, Image, HStack, VStack } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { StaggerContainer, StaggerItem, ScrollSlideUp } from "@/components/motion";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import MatchCard from "./match-card";
import type {
LeagueWithMatchesDto,
MatchResponseDto,
} from "@/lib/api/matches/types";
// ========================
// Match Card Skeleton — realistic loading placeholder
// ========================
function MatchCardSkeleton() {
const bg = useColorModeValue("white", "gray.800");
const border = useColorModeValue("gray.100", "gray.700");
return (
<Box
bg={bg}
borderWidth="1px"
borderColor={border}
borderRadius="xl"
p={4}
className="animate-shimmer"
>
{/* Status + Date */}
<Flex justify="space-between" align="center" mb={3}>
<Skeleton borderRadius="full" height="20px" width="60px" />
<Skeleton borderRadius="md" height="14px" width="80px" />
</Flex>
{/* Teams */}
<HStack gap={3} justify="space-between">
{/* Home */}
<VStack gap={1.5} flex={1} align="center">
<Skeleton boxSize="40px" borderRadius="full" />
<Skeleton height="14px" width="70px" />
</VStack>
{/* VS / Score */}
<Skeleton height="24px" width="30px" borderRadius="md" />
{/* Away */}
<VStack gap={1.5} flex={1} align="center">
<Skeleton boxSize="40px" borderRadius="full" />
<Skeleton height="14px" width="70px" />
</VStack>
</HStack>
{/* League */}
<Flex mt={3} pt={2} borderTopWidth="1px" borderColor={border} justify="center">
<Skeleton height="12px" width="120px" />
</Flex>
</Box>
);
}
/** Skeleton grid for loading state */
function MatchListSkeleton() {
return (
<Box>
{/* Fake league header */}
<Skeleton height="44px" borderRadius="xl" mb={3} />
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
}}
gap={3}
>
{Array.from({ length: 6 }).map((_, i) => (
<MatchCardSkeleton key={i} />
))}
</Grid>
</Box>
);
}
interface MatchListProps {
leagues?: LeagueWithMatchesDto[];
flatMatches?: MatchResponseDto[];
isLoading?: boolean;
}
/**
* MatchList — renders matches grouped by league, or flat if only flatMatches is provided.
*/
export default function MatchList({
leagues,
flatMatches,
isLoading,
}: MatchListProps) {
const t = useTranslations("matches");
const leagueHeaderBg = useColorModeValue("gray.50", "gray.900");
const borderColor = useColorModeValue("gray.200", "gray.700");
if (isLoading) {
return <MatchListSkeleton />;
}
// Flat mode — no league grouping
if (flatMatches) {
if (flatMatches.length === 0) {
return (
<Flex justify="center" align="center" py={16}>
<Text color="fg.muted" fontSize="md">
{t("no-matches")}
</Text>
</Flex>
);
}
return (
<StaggerContainer>
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
}}
gap={4}
>
{flatMatches.map((match) => (
<StaggerItem key={match.id}>
<MatchCard match={match} />
</StaggerItem>
))}
</Grid>
</StaggerContainer>
);
}
// Grouped mode — grouped by league
if (!leagues || leagues.length === 0) {
return (
<Flex justify="center" align="center" py={16}>
<Text color="fg.muted" fontSize="md">
{t("no-matches")}
</Text>
</Flex>
);
}
return (
<StaggerContainer>
{leagues.map((league) => (
<StaggerItem key={league.id}>
<Box mb={6}>
{/* League Header */}
<Flex
align="center"
gap={2}
px={4}
py={2.5}
bg={leagueHeaderBg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
mb={3}
>
{league.country?.flagUrl && (
<Image
src={league.country.flagUrl}
alt={league.country.name || ""}
boxSize="20px"
objectFit="contain"
/>
)}
<Text fontSize="sm" fontWeight="bold">
{league.country?.name && `${league.country.name}`}
{league.name}
</Text>
<Text fontSize="xs" color="fg.muted" ml="auto">
{league.matches.length} {t("title").toLowerCase()}
</Text>
</Flex>
{/* Match Grid */}
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
}}
gap={3}
>
{league.matches.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</Grid>
</Box>
</StaggerItem>
))}
</StaggerContainer>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { Box, Flex, Heading } from "@chakra-ui/react";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { SlideUp } from "@/components/motion";
import { SportFilter, LeagueSidebar, MatchList } from "@/components/matches";
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
import { useMatchStore } from "@/lib/stores/match-store";
export default function MatchesContent() {
const t = useTranslations("matches");
const sport = useMatchStore((s) => s.sport);
const leagueFilter = useMatchStore((s) => s.leagueFilter);
const setSport = useMatchStore((s) => s.setSport);
const setLeague = useMatchStore((s) => s.setLeague);
// Fetch active leagues for sidebar
const { data: leaguesData, isLoading: leaguesLoading } =
useActiveLeagues(sport);
const leagues = leaguesData?.data ?? [];
// Query matches grouped by league
const queryMatches = useQueryMatches();
// Trigger query on sport/league change
const { data: matchesData, isPending: matchesLoading } = (() => {
// We use the queryMatches mutation for initial data
// but for the UI we want a reactive approach.
// Let's use the standard list with league filter
return {
data: queryMatches.data,
isPending: queryMatches.isPending,
};
})();
// Auto-trigger query when sport or league changes
const handleSportChange = (newSport: typeof sport) => {
setSport(newSport);
queryMatches.mutate({
sport: newSport,
leagueId: undefined,
limit: 100,
});
};
const handleLeagueChange = (leagueId: string | null) => {
setLeague(leagueId);
queryMatches.mutate({
sport,
leagueId: leagueId || undefined,
limit: 100,
});
};
// Initial load
useEffect(() => {
if (!queryMatches.data && !queryMatches.isPending) {
queryMatches.mutate({
sport,
leagueId: leagueFilter || undefined,
limit: 100,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const matchLeagues = matchesData?.data ?? [];
return (
<SlideUp>
<Box>
{/* Page Header */}
<Flex
justify="space-between"
align="center"
mb={6}
flexWrap="wrap"
gap={3}
>
<Heading as="h1" size="xl" fontWeight="bold">
{t("title")}
</Heading>
<SportFilter value={sport} onChange={handleSportChange} />
</Flex>
{/* Main Content */}
<Flex
gap={6}
align="flex-start"
direction={{ base: "column", lg: "row" }}
>
{/* League Sidebar (Desktop only) */}
<Box
display={{ base: "none", lg: "block" }}
w="260px"
flexShrink={0}
position="sticky"
top="80px"
>
<LeagueSidebar
leagues={leagues}
selectedLeagueId={leagueFilter}
onSelect={handleLeagueChange}
isLoading={leaguesLoading}
/>
</Box>
{/* Match List */}
<Box flex={1} minW={0}>
<MatchList
leagues={matchLeagues}
isLoading={queryMatches.isPending}
/>
</Box>
</Flex>
</Box>
</SlideUp>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
import {
Box,
SimpleGrid,
Text,
Badge,
Card,
VStack,
Flex,
} from "@chakra-ui/react";
import { useColorModeValue } from "@/components/ui/color-mode";
interface OddsCardProps {
odds?: Record<string, Record<string, { odd: string }>>;
}
interface MarketBlockProps {
title: string;
selections: Record<string, { odd: string }>;
}
function MarketBlock({ title, selections }: MarketBlockProps) {
const bg = useColorModeValue("gray.50", "gray.800");
const borderColor = useColorModeValue("gray.200", "gray.700");
const selectionBg = useColorModeValue("white", "gray.700");
// Sort selections based on common market patterns
const sortedKeys = Object.keys(selections).sort((a, b) => {
// MS: 1, X, 2
if (["1", "X", "2"].includes(a) && ["1", "X", "2"].includes(b)) {
const order = ["1", "X", "2"];
return order.indexOf(a) - order.indexOf(b);
}
// Alt/Üst: Alt, Üst
if (["Alt", "Üst"].includes(a) && ["Alt", "Üst"].includes(b)) {
return a === "Alt" ? -1 : 1; // Alt first
}
// KG: Var, Yok
if (["Var", "Yok"].includes(a) && ["Var", "Yok"].includes(b)) {
return a === "Var" ? -1 : 1; // Var first
}
return a.localeCompare(b);
});
return (
<Box
bg={bg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
>
<Box px={3} py={1.5} borderBottomWidth="1px" borderColor={borderColor}>
<Text
fontSize="xs"
fontWeight="bold"
color="fg.muted"
textTransform="uppercase"
>
{title}
</Text>
</Box>
<Flex p={2} gap={2} wrap="wrap">
{sortedKeys.map((key) => (
<Flex
key={key}
direction="column"
align="center"
justify="center"
bg={selectionBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="sm"
minW="50px"
py={1}
px={2}
flex={1}
>
<Text fontSize="xs" color="fg.muted" mb={0.5}>
{key}
</Text>
<Text fontSize="sm" fontWeight="bold" color="primary.500">
{Number(selections[key].odd).toFixed(2)}
</Text>
</Flex>
))}
</Flex>
</Box>
);
}
export default function OddsCard({ odds }: OddsCardProps) {
const cardBg = useColorModeValue("white", "gray.900");
const borderColor = useColorModeValue("gray.100", "gray.800");
if (!odds || Object.keys(odds).length === 0) {
return null;
}
// Define priority markets to show at the top
const PRIORITY_MARKETS = [
"Maç Sonucu",
"2.5 Alt/Üst",
"Karşılıklı Gol",
"İlk Yarı Sonucu",
"1. Yarı Sonucu",
"Kart",
"Korner",
];
const marketKeys = Object.keys(odds);
const priorityKeys = marketKeys.filter((k) =>
PRIORITY_MARKETS.some((pm) => k.includes(pm)),
);
const otherKeys = marketKeys.filter(
(k) => !PRIORITY_MARKETS.some((pm) => k.includes(pm)),
);
// Group similar markets if needed, but simple list for now
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
<Card.Body>
<VStack align="stretch" gap={4}>
<Text fontSize="lg" fontWeight="bold">
Canlı İddaa Oranları
</Text>
{/* Priority Markets Grid */}
{priorityKeys.length > 0 && (
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
{priorityKeys.map((key) => (
<MarketBlock key={key} title={key} selections={odds[key]} />
))}
</SimpleGrid>
)}
{/* Other Markets - Show ALL */}
{otherKeys.length > 0 && (
<SimpleGrid
columns={{ base: 1, md: 2, lg: 3 }}
gap={4}
mt={priorityKeys.length > 0 ? 2 : 0}
>
{otherKeys.map((key) => (
<MarketBlock key={key} title={key} selections={odds[key]} />
))}
</SimpleGrid>
)}
</VStack>
</Card.Body>
</Card.Root>
);
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { HStack, Button } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { LuCircleDot } from "react-icons/lu";
import { MdSportsSoccer, MdSportsBasketball } from "react-icons/md";
import type { SportType } from "@/lib/api/matches/types";
interface SportFilterProps {
value: SportType;
onChange: (sport: SportType) => void;
}
const SPORT_OPTIONS: { value: SportType; icon: React.ReactNode }[] = [
{ value: "football", icon: <MdSportsSoccer /> },
{ value: "basketball", icon: <MdSportsBasketball /> },
];
export default function SportFilter({ value, onChange }: SportFilterProps) {
const t = useTranslations("matches");
return (
<HStack gap={2}>
{SPORT_OPTIONS.map((sport) => {
const isActive = value === sport.value;
return (
<Button
key={sport.value}
onClick={() => onChange(sport.value)}
variant={isActive ? "solid" : "outline"}
colorPalette={isActive ? "primary" : "gray"}
size="sm"
borderRadius="full"
gap={1.5}
>
{sport.icon}
{t(sport.value)}
{isActive && <LuCircleDot size={12} />}
</Button>
);
})}
</HStack>
);
}
+470
View File
@@ -0,0 +1,470 @@
"use client";
import {
motion,
useMotionValue,
useTransform,
animate,
useInView,
type HTMLMotionProps,
} from "framer-motion";
import { forwardRef, type ReactNode, useEffect, useRef } from "react";
// ========================
// Shared animation variants
// ========================
export const fadeInVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
export const slideUpVariants = {
hidden: { opacity: 0, y: 24 },
visible: { opacity: 1, y: 0 },
};
export const slideDownVariants = {
hidden: { opacity: 0, y: -24 },
visible: { opacity: 1, y: 0 },
};
export const slideLeftVariants = {
hidden: { opacity: 0, x: 40 },
visible: { opacity: 1, x: 0 },
};
export const slideRightVariants = {
hidden: { opacity: 0, x: -40 },
visible: { opacity: 1, x: 0 },
};
export const scaleInVariants = {
hidden: { opacity: 0, scale: 0.9 },
visible: { opacity: 1, scale: 1 },
};
export const blurInVariants = {
hidden: { opacity: 0, filter: "blur(10px)" },
visible: { opacity: 1, filter: "blur(0px)" },
};
export const staggerContainerVariants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08,
},
},
};
// ========================
// Spring presets
// ========================
export const springs = {
gentle: { type: "spring" as const, stiffness: 120, damping: 14 },
bouncy: { type: "spring" as const, stiffness: 300, damping: 15 },
snappy: { type: "spring" as const, stiffness: 400, damping: 25 },
smooth: { duration: 0.5, ease: [0.25, 0.1, 0.25, 1] as const },
};
// ========================
// Generic Motion Wrappers (animate on mount)
// ========================
interface MotionWrapperProps extends HTMLMotionProps<"div"> {
children: ReactNode;
}
export const FadeIn = forwardRef<HTMLDivElement, MotionWrapperProps>(
({ children, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
animate="visible"
variants={fadeInVariants}
transition={{ duration: 0.4 }}
{...props}
>
{children}
</motion.div>
),
);
FadeIn.displayName = "FadeIn";
export const SlideUp = forwardRef<HTMLDivElement, MotionWrapperProps>(
({ children, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
animate="visible"
variants={slideUpVariants}
transition={springs.smooth}
{...props}
>
{children}
</motion.div>
),
);
SlideUp.displayName = "SlideUp";
export const ScaleIn = forwardRef<HTMLDivElement, MotionWrapperProps>(
({ children, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
animate="visible"
variants={scaleInVariants}
transition={springs.gentle}
{...props}
>
{children}
</motion.div>
),
);
ScaleIn.displayName = "ScaleIn";
export const BlurIn = forwardRef<HTMLDivElement, MotionWrapperProps>(
({ children, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
animate="visible"
variants={blurInVariants}
transition={{ duration: 0.6 }}
{...props}
>
{children}
</motion.div>
),
);
BlurIn.displayName = "BlurIn";
// ========================
// Scroll-based motion (whileInView)
// ========================
interface ScrollMotionProps extends HTMLMotionProps<"div"> {
children: ReactNode;
/** How much of the element must be visible (01). Default: 0.2 */
threshold?: number;
/** Animate only once? Default: true */
once?: boolean;
}
export const ScrollFadeIn = forwardRef<HTMLDivElement, ScrollMotionProps>(
({ children, threshold = 0.2, once = true, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={{ once, amount: threshold }}
variants={fadeInVariants}
transition={{ duration: 0.5 }}
{...props}
>
{children}
</motion.div>
),
);
ScrollFadeIn.displayName = "ScrollFadeIn";
export const ScrollSlideUp = forwardRef<HTMLDivElement, ScrollMotionProps>(
({ children, threshold = 0.15, once = true, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={{ once, amount: threshold }}
variants={slideUpVariants}
transition={springs.smooth}
{...props}
>
{children}
</motion.div>
),
);
ScrollSlideUp.displayName = "ScrollSlideUp";
export const ScrollScaleIn = forwardRef<HTMLDivElement, ScrollMotionProps>(
({ children, threshold = 0.2, once = true, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={{ once, amount: threshold }}
variants={scaleInVariants}
transition={springs.gentle}
{...props}
>
{children}
</motion.div>
),
);
ScrollScaleIn.displayName = "ScrollScaleIn";
export const ScrollBlurIn = forwardRef<HTMLDivElement, ScrollMotionProps>(
({ children, threshold = 0.2, once = true, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={{ once, amount: threshold }}
variants={blurInVariants}
transition={{ duration: 0.7 }}
{...props}
>
{children}
</motion.div>
),
);
ScrollBlurIn.displayName = "ScrollBlurIn";
export const ScrollSlideLeft = forwardRef<HTMLDivElement, ScrollMotionProps>(
({ children, threshold = 0.15, once = true, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={{ once, amount: threshold }}
variants={slideLeftVariants}
transition={springs.smooth}
{...props}
>
{children}
</motion.div>
),
);
ScrollSlideLeft.displayName = "ScrollSlideLeft";
export const ScrollSlideRight = forwardRef<HTMLDivElement, ScrollMotionProps>(
({ children, threshold = 0.15, once = true, ...props }, ref) => (
<motion.div
ref={ref}
initial="hidden"
whileInView="visible"
viewport={{ once, amount: threshold }}
variants={slideRightVariants}
transition={springs.smooth}
{...props}
>
{children}
</motion.div>
),
);
ScrollSlideRight.displayName = "ScrollSlideRight";
// ========================
// Stagger Container — animate children one by one
// ========================
interface StaggerProps extends HTMLMotionProps<"div"> {
children: ReactNode;
staggerDelay?: number;
/** Use whileInView instead of animate? Default: false */
inView?: boolean;
}
export const StaggerContainer = forwardRef<HTMLDivElement, StaggerProps>(
({ children, staggerDelay = 0.08, inView = false, ...props }, ref) => {
const baseProps = {
ref,
variants: {
hidden: {},
visible: { transition: { staggerChildren: staggerDelay } },
},
...props,
};
if (inView) {
return (
<motion.div
{...baseProps}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.1 }}
>
{children}
</motion.div>
);
}
return (
<motion.div {...baseProps} initial="hidden" animate="visible">
{children}
</motion.div>
);
},
);
StaggerContainer.displayName = "StaggerContainer";
// ========================
// Stagger Item — use as direct child of StaggerContainer
// ========================
export const StaggerItem = forwardRef<HTMLDivElement, MotionWrapperProps>(
({ children, ...props }, ref) => (
<motion.div
ref={ref}
variants={slideUpVariants}
transition={springs.smooth}
{...props}
>
{children}
</motion.div>
),
);
StaggerItem.displayName = "StaggerItem";
// ========================
// Animated Counter — REAL counting with number interpolation
// ========================
interface AnimatedCounterProps {
/** Target value to count up to */
value: number;
suffix?: string;
prefix?: string;
/** Duration in seconds. Default: 2 */
duration?: number;
/** Only animate when visible? Default: true */
inView?: boolean;
}
export function AnimatedCounter({
value,
suffix = "",
prefix = "",
duration = 2,
inView = true,
}: AnimatedCounterProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(0);
const rounded = useTransform(motionValue, (latest) =>
Intl.NumberFormat("tr-TR").format(Math.round(latest)),
);
const isInView = useInView(ref, { once: true, amount: 0.5 });
useEffect(() => {
if (!inView || isInView) {
const controls = animate(motionValue, value, {
duration,
ease: [0.25, 0.1, 0.25, 1],
});
return controls.stop;
}
}, [motionValue, value, duration, inView, isInView]);
return (
<motion.span
ref={ref}
initial={{ opacity: 0, scale: 0.8 }}
animate={isInView || !inView ? { opacity: 1, scale: 1 } : {}}
transition={{ type: "spring", stiffness: 200, damping: 15 }}
>
{prefix}
<motion.span>{rounded}</motion.span>
{suffix}
</motion.span>
);
}
// ========================
// Sparkle / Particle Effect
// ========================
interface SparkleProps {
/** Number of sparkle particles. Default: 6 */
count?: number;
/** Color of the sparkle. Default: "primary.300" */
color?: string;
}
export function Sparkles({ count = 6, color = "rgba(56, 178, 172, 0.6)" }: SparkleProps) {
return (
<div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
{Array.from({ length: count }).map((_, i) => (
<motion.div
key={i}
style={{
position: "absolute",
width: 4 + Math.random() * 4,
height: 4 + Math.random() * 4,
borderRadius: "50%",
background: color,
left: `${10 + Math.random() * 80}%`,
bottom: `${Math.random() * 30}%`,
}}
animate={{
y: [0, -(60 + Math.random() * 80)],
opacity: [0, 1, 1, 0],
scale: [0.5, 1, 0.8, 0],
}}
transition={{
duration: 2.5 + Math.random() * 2,
repeat: Infinity,
delay: Math.random() * 3,
ease: "easeOut",
}}
/>
))}
</div>
);
}
// ========================
// Gradient Orb — decorative floating orb
// ========================
interface GradientOrbProps {
/** CSS color/gradient for the orb */
color?: string;
/** Size in px */
size?: number;
/** Position: top, left, right, bottom (CSS values) */
top?: string;
left?: string;
right?: string;
bottom?: string;
/** Blur amount in px. Default: 60 */
blur?: number;
}
export function GradientOrb({
color = "rgba(56, 178, 172, 0.15)",
size = 200,
top,
left,
right,
bottom,
blur = 60,
}: GradientOrbProps) {
return (
<motion.div
style={{
position: "absolute",
width: size,
height: size,
borderRadius: "50%",
background: color,
filter: `blur(${blur}px)`,
top,
left,
right,
bottom,
pointerEvents: "none",
zIndex: 0,
}}
animate={{
y: [0, -15, 0],
scale: [1, 1.05, 1],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut",
}}
/>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as PredictionsContent } from "./predictions-content";
@@ -0,0 +1,352 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Badge,
VStack,
HStack,
Card,
SimpleGrid,
Spinner,
Button,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, StaggerContainer, StaggerItem } from "@/components/motion";
import type { SportType } from "@/lib/api/matches/types";
import {
useUpcomingPredictions,
useValueBets,
usePredictionHistory,
} from "@/lib/api/predictions/use-hooks";
import type {
MatchPredictionDto,
ValueBetDto,
PredictionHistoryResponseDto,
} from "@/lib/api/predictions/types";
import { useState } from "react";
function getPredictionSport(prediction: MatchPredictionDto): SportType {
const explicitSport = prediction.match_info?.sport;
if (explicitSport === "basketball" || explicitSport === "football") {
return explicitSport;
}
if (
prediction.model_version?.toLowerCase().includes("basketball") ||
Object.keys(prediction.market_board || {}).some((market) =>
["ML", "TOTAL", "SPREAD"].includes(market),
)
) {
return "basketball";
}
return "football";
}
type TabType = "upcoming" | "value-bets" | "history";
export default function PredictionsContent() {
const t = useTranslations("predictions");
const tMatches = useTranslations("matches");
const router = useRouter();
const [activeTab, setActiveTab] = useState<TabType>("upcoming");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const { data: upcomingData, isLoading: upcomingLoading } =
useUpcomingPredictions();
const { data: valueBetsData, isLoading: valueBetsLoading } = useValueBets();
const { data: historyData, isLoading: historyLoading } =
usePredictionHistory();
const upcomingPredictions: MatchPredictionDto[] =
upcomingData?.data?.matches ?? [];
const valueBets: ValueBetDto[] = valueBetsData?.data ?? [];
const historyResponse = historyData?.data as
| PredictionHistoryResponseDto
| undefined;
const history = historyResponse?.history ?? [];
const riskColors: Record<string, string> = {
LOW: "green",
MEDIUM: "yellow",
HIGH: "orange",
"VERY HIGH": "red",
};
const tabs: { key: TabType; label: string }[] = [
{ key: "upcoming", label: t("upcoming") },
{ key: "value-bets", label: t("value-bets") },
{ key: "history", label: t("history") },
];
return (
<SlideUp>
<Box>
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
{t("title")}
</Heading>
{/* Tabs */}
<HStack gap={2} mb={6} overflowX="auto" pb={1}>
{tabs.map((tab) => (
<Button
key={tab.key}
variant={activeTab === tab.key ? "solid" : "outline"}
colorPalette={activeTab === tab.key ? "primary" : "gray"}
size="sm"
borderRadius="full"
onClick={() => setActiveTab(tab.key)}
flexShrink={0}
>
{tab.label}
</Button>
))}
</HStack>
{/* Upcoming Predictions Tab */}
{activeTab === "upcoming" &&
(upcomingLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : upcomingPredictions.length > 0 ? (
<StaggerContainer>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
{upcomingPredictions.map(
(pred: MatchPredictionDto, idx: number) => {
const sport = getPredictionSport(pred);
return (
<StaggerItem key={idx}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
cursor="pointer"
_hover={{ shadow: "lg", borderColor: "primary.300" }}
transition="all 0.2s"
onClick={() =>
router.push(`/matches/${pred.match_info.match_id}`)
}
>
<Card.Body>
<Flex justify="space-between" align="center" mb={3}>
<Text fontSize="sm" fontWeight="bold">
{pred.match_info.home_team} vs{" "}
{pred.match_info.away_team}
</Text>
<HStack gap={2}>
<Badge
colorPalette={
sport === "basketball" ? "orange" : "blue"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{tMatches(sport)}
</Badge>
<Badge
colorPalette={
riskColors[pred.risk?.level?.toUpperCase()] ||
"gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{pred.risk?.level}
</Badge>
</HStack>
</Flex>
{pred.main_pick && (
<Box
p={3}
bg="primary.subtle"
borderRadius="lg"
mb={3}
>
<Flex justify="space-between" align="center">
<VStack gap={0} align="flex-start">
<Text fontSize="xs" color="fg.muted">
{t("main-pick")}
</Text>
<Text fontSize="md" fontWeight="bold">
{pred.main_pick.pick}
</Text>
</VStack>
<VStack gap={0} align="flex-end">
<Text fontSize="xs" color="fg.muted">
{t("confidence")}
</Text>
<Text
fontSize="md"
fontWeight="bold"
color="primary.fg"
>
{Math.round(
pred.main_pick.calibrated_confidence ??
pred.main_pick.confidence,
)}
%
</Text>
</VStack>
</Flex>
</Box>
)}
<HStack justify="space-between">
<Text fontSize="xs" color="fg.muted">
{t("data-quality")}:{" "}
{Math.round(
(pred.data_quality?.score ?? 0) * 100,
)}
%
</Text>
<Text fontSize="xs" color="fg.muted">
{pred.model_version}
</Text>
</HStack>
</Card.Body>
</Card.Root>
</StaggerItem>
);
},
)}
</SimpleGrid>
</StaggerContainer>
) : (
<Flex justify="center" py={16}>
<Text color="fg.muted">{t("no-predictions")}</Text>
</Flex>
))}
{/* Value Bets Tab */}
{activeTab === "value-bets" &&
(valueBetsLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : valueBets.length > 0 ? (
<StaggerContainer>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={4}>
{valueBets.map((vb: ValueBetDto, idx: number) => (
<StaggerItem key={idx}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body>
<Text fontSize="sm" color="fg.muted" truncate mb={2}>
{vb.matchName}
</Text>
<Flex justify="space-between" align="center" mb={2}>
<Text fontSize="lg" fontWeight="bold">
{vb.prediction}
</Text>
<Badge
colorPalette="primary"
variant="subtle"
borderRadius="full"
>
{vb.odd.toFixed(2)}
</Badge>
</Flex>
<HStack justify="space-between">
<Badge
colorPalette="green"
variant="solid"
fontSize="xs"
borderRadius="full"
>
EV+ {(vb.expectedValue * 100).toFixed(0)}%
</Badge>
<Text fontSize="xs" color="fg.muted">
{t("confidence")}: {Math.round(vb.confidence * 100)}
%
</Text>
</HStack>
</Card.Body>
</Card.Root>
</StaggerItem>
))}
</SimpleGrid>
</StaggerContainer>
) : (
<Flex justify="center" py={16}>
<Text color="fg.muted">{t("no-predictions")}</Text>
</Flex>
))}
{/* History Tab */}
{activeTab === "history" &&
(historyLoading ? (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : history.length > 0 ? (
<StaggerContainer>
<VStack gap={3} align="stretch">
{history.map((item: Record<string, unknown>, idx: number) => (
<StaggerItem key={idx}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body>
<Flex justify="space-between" align="center">
<VStack gap={0} align="flex-start">
<Text fontSize="sm" fontWeight="bold">
{String(item.homeTeam ?? "")} vs{" "}
{String(item.awayTeam ?? "")}
</Text>
<Text fontSize="xs" color="fg.muted">
{String(item.pick ?? "")}
</Text>
</VStack>
<VStack gap={0} align="flex-end">
<Badge
colorPalette={
item.result === "correct"
? "green"
: item.result === "incorrect"
? "red"
: "gray"
}
variant="subtle"
fontSize="xs"
borderRadius="full"
>
{String(item.result ?? "pending")}
</Badge>
<Text fontSize="xs" color="fg.muted">
{typeof item.confidence === "number"
? `${Math.round(item.confidence * 100)}%`
: "—"}
</Text>
</VStack>
</Flex>
</Card.Body>
</Card.Root>
</StaggerItem>
))}
</VStack>
</StaggerContainer>
) : (
<Flex justify="center" py={16}>
<Text color="fg.muted">{t("no-predictions")}</Text>
</Flex>
))}
</Box>
</SlideUp>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as ProfileContent } from "./profile-content";
+422
View File
@@ -0,0 +1,422 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Card,
VStack,
HStack,
Separator,
Spinner,
Input,
Button,
IconButton,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import { Avatar } from "@/components/ui/data-display/avatar";
import { useSession } from "next-auth/react";
import { useUserBettingStats } from "@/lib/api/coupons/use-hooks";
import type { UserBettingStatsDto } from "@/lib/api/coupons/types";
import {
LuMail,
LuUser,
LuCalendar,
LuShield,
LuTrendingUp,
LuTarget,
LuTicket,
LuPen,
LuCheck,
LuX,
LuLock,
} from "react-icons/lu";
import { useState } from "react";
import { useUpdateProfile, useChangePassword } from "@/lib/api/users/use-hooks";
import { Field } from "@/components/ui/forms/field";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { useRouter } from "next/navigation";
interface InfoRowProps {
icon: React.ReactNode;
label: string;
value: string;
}
function InfoRow({ icon, label, value }: InfoRowProps) {
return (
<Flex justify="space-between" align="center" py={2}>
<HStack gap={2} color="fg.muted">
{icon}
<Text fontSize="sm">{label}</Text>
</HStack>
<Text fontSize="sm" fontWeight="semibold">
{value}
</Text>
</Flex>
);
}
const profileSchema = yup.object({
firstName: yup.string().required(),
lastName: yup.string().required(),
});
type ProfileForm = yup.InferType<typeof profileSchema>;
const passwordSchema = yup.object({
currentPassword: yup.string().required(),
newPassword: yup.string().min(8).required(),
confirmPassword: yup
.string()
.oneOf([yup.ref("newPassword")], "Passwords must match")
.required(),
});
type PasswordForm = yup.InferType<typeof passwordSchema>;
export default function ProfileContent() {
const t = useTranslations("profile");
const tCommon = useTranslations("common");
const { data: session, update: updateSession } = useSession();
const { data: statsData, isLoading: statsLoading } = useUserBettingStats();
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const user = session?.user;
const stats = statsData?.data as UserBettingStatsDto | undefined;
// Edit profile state
const [isEditing, setIsEditing] = useState(false);
const updateProfile = useUpdateProfile();
const {
handleSubmit: handleProfileSubmit,
register: profileRegister,
formState: { errors: profileErrors },
reset: resetProfile,
} = useForm<ProfileForm>({
resolver: yupResolver(profileSchema),
mode: "onChange",
defaultValues: {
firstName: user?.name?.split(" ")[0] || "",
lastName: user?.name?.split(" ").slice(1).join(" ") || "",
},
});
const onProfileSubmit = async (data: ProfileForm) => {
await updateProfile.mutateAsync(data);
await updateSession();
setIsEditing(false);
};
// Change password state
const [showPasswordForm, setShowPasswordForm] = useState(false);
const changePassword = useChangePassword();
const router = useRouter();
const {
handleSubmit: handlePasswordSubmit,
register: passwordRegister,
formState: { errors: passwordErrors },
reset: resetPassword,
} = useForm<PasswordForm>({
resolver: yupResolver(passwordSchema),
mode: "onChange",
});
const onPasswordSubmit = async (data: PasswordForm) => {
await changePassword.mutateAsync({
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
resetPassword();
setShowPasswordForm(false);
};
return (
<SlideUp>
<Box maxW="2xl" mx="auto">
<Heading as="h1" size="xl" fontWeight="bold" mb={6}>
{t("title")}
</Heading>
{/* Profile Card */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Body>
<Flex
direction={{ base: "column", sm: "row" }}
align="center"
gap={6}
>
<Avatar name={user?.name || "User"} variant="solid" size="2xl" />
<VStack gap={1} align={{ base: "center", sm: "flex-start" }}>
<Heading as="h2" size="lg">
{user?.name || "—"}
</Heading>
<Text color="fg.muted" fontSize="sm">
{user?.email || "—"}
</Text>
</VStack>
</Flex>
</Card.Body>
</Card.Root>
{/* Account Info */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Header>
<Flex justify="space-between" align="center">
<Heading as="h3" size="sm">
{t("personal-info")}
</Heading>
{!isEditing ? (
<IconButton
variant="ghost"
size="sm"
aria-label={tCommon("edit")}
onClick={() => setIsEditing(true)}
>
<LuPen />
</IconButton>
) : null}
</Flex>
</Card.Header>
<Card.Body pt={0}>
{isEditing ? (
<Flex
as="form"
direction="column"
gap={4}
onSubmit={handleProfileSubmit(onProfileSubmit)}
>
<Field
label={t("first-name")}
errorText={profileErrors.firstName?.message}
invalid={!!profileErrors.firstName}
>
<Input
{...profileRegister("firstName")}
placeholder={t("first-name")}
/>
</Field>
<Field
label={t("last-name")}
errorText={profileErrors.lastName?.message}
invalid={!!profileErrors.lastName}
>
<Input
{...profileRegister("lastName")}
placeholder={t("last-name")}
/>
</Field>
<HStack gap={2} justify="flex-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEditing(false);
resetProfile();
}}
>
<LuX />
{tCommon("cancel")}
</Button>
<Button
type="submit"
size="sm"
loading={updateProfile.isPending}
>
<LuCheck />
{tCommon("save")}
</Button>
</HStack>
</Flex>
) : (
<>
<InfoRow
icon={<LuUser />}
label={t("full-name")}
value={user?.name || "—"}
/>
<Separator />
<InfoRow
icon={<LuMail />}
label={t("email")}
value={user?.email || "—"}
/>
<Separator />
<InfoRow
icon={<LuShield />}
label={t("role")}
value={
(user as Record<string, unknown>)?.roles
? String((user as Record<string, unknown>).roles)
: "User"
}
/>
<Separator />
<InfoRow
icon={<LuCalendar />}
label={t("member-since")}
value="—"
/>
</>
)}
</Card.Body>
</Card.Root>
{/* Change Password */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Header>
<Flex justify="space-between" align="center">
<Heading as="h3" size="sm">
<HStack gap={2}>
<LuLock />
<Text>{t("change-password")}</Text>
</HStack>
</Heading>
{!showPasswordForm ? (
<Button
variant="outline"
size="sm"
onClick={() => setShowPasswordForm(true)}
>
{tCommon("edit")}
</Button>
) : null}
</Flex>
</Card.Header>
<Card.Body pt={0}>
{showPasswordForm ? (
<Flex
as="form"
direction="column"
gap={4}
onSubmit={handlePasswordSubmit(onPasswordSubmit)}
>
<Field
label={t("current-password")}
errorText={passwordErrors.currentPassword?.message}
invalid={!!passwordErrors.currentPassword}
>
<PasswordInput
{...passwordRegister("currentPassword")}
placeholder={t("current-password")}
/>
</Field>
<Field
label={t("new-password")}
errorText={passwordErrors.newPassword?.message}
invalid={!!passwordErrors.newPassword}
>
<PasswordInput
{...passwordRegister("newPassword")}
placeholder={t("new-password")}
/>
</Field>
<Field
label={t("confirm-password")}
errorText={passwordErrors.confirmPassword?.message}
invalid={!!passwordErrors.confirmPassword}
>
<PasswordInput
{...passwordRegister("confirmPassword")}
placeholder={t("confirm-password")}
/>
</Field>
<HStack gap={2} justify="flex-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setShowPasswordForm(false);
resetPassword();
}}
>
<LuX />
{tCommon("cancel")}
</Button>
<Button
type="submit"
size="sm"
loading={changePassword.isPending}
onClick={() => router.refresh()}
>
<LuCheck />
{tCommon("save")}
</Button>
</HStack>
</Flex>
) : (
<Text fontSize="sm" color="fg.muted" py={2}>
{t("change-password-desc")}
</Text>
)}
</Card.Body>
</Card.Root>
{/* Betting Stats */}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Header>
<Heading as="h3" size="sm">
{t("betting-stats")}
</Heading>
</Card.Header>
<Card.Body pt={0}>
{statsLoading ? (
<Flex justify="center" py={6}>
<Spinner size="sm" color="primary.500" />
</Flex>
) : (
<>
<InfoRow
icon={<LuTicket />}
label={t("total-coupons")}
value={String(stats?.totalCoupons ?? "—")}
/>
<Separator />
<InfoRow
icon={<LuTrendingUp />}
label={t("win-rate")}
value={
stats?.winRate != null
? `${Math.round(stats.winRate)}%`
: "—"
}
/>
<Separator />
<InfoRow
icon={<LuTarget />}
label={t("total-profit")}
value={stats?.wonBets != null ? String(stats.wonBets) : "—"}
/>
</>
)}
</Card.Body>
</Card.Root>
</Box>
</SlideUp>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import {
Box,
Flex,
Input,
Text,
VStack,
HStack,
Image,
Spinner,
} from "@chakra-ui/react";
import { useColorModeValue } from "@/components/ui/color-mode";
import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
import { useRouter } from "@/i18n/navigation";
import { LuSearch, LuX } from "react-icons/lu";
import type { TeamDto } from "@/lib/api/leagues/types";
export default function GlobalSearch() {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [debouncedQuery, setDebouncedQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const bg = useColorModeValue("white", "gray.900");
const borderColor = useColorModeValue("gray.200", "gray.700");
const hoverBg = useColorModeValue("gray.50", "gray.800");
const inputBg = useColorModeValue("gray.50", "gray.800");
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
const { data: searchData, isLoading } = useSearchTeams({
q: debouncedQuery,
});
const teams: TeamDto[] = searchData?.data ?? [];
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Keyboard shortcut: Ctrl+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
inputRef.current?.focus();
setIsOpen(true);
}
if (e.key === "Escape") {
setIsOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
const handleTeamClick = useCallback(
(team: TeamDto) => {
setIsOpen(false);
setQuery("");
router.push(`/teams/${team.id}`);
},
[router],
);
return (
<Box ref={containerRef} position="relative" w={{ base: "full", lg: "280px" }}>
{/* Search Input */}
<Flex
align="center"
bg={inputBg}
borderRadius="full"
border="1px solid"
borderColor={isOpen ? "primary.400" : borderColor}
px={3}
py={1}
transition="all 0.2s"
_focusWithin={{
borderColor: "primary.400",
shadow: "0 0 0 1px var(--chakra-colors-primary-400)",
}}
>
<LuSearch
style={{ flexShrink: 0, opacity: 0.5, width: 16, height: 16 }}
/>
<Input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => query.length >= 2 && setIsOpen(true)}
placeholder="Takım ara... (Ctrl+K)"
variant="flushed"
size="sm"
px={2}
fontSize="sm"
/>
{query && (
<Box
as="button"
onClick={() => {
setQuery("");
setIsOpen(false);
}}
cursor="pointer"
opacity={0.5}
_hover={{ opacity: 1 }}
flexShrink={0}
>
<LuX style={{ width: 14, height: 14 }} />
</Box>
)}
<Text
display={{ base: "none", lg: "block" }}
fontSize="xs"
color="fg.muted"
flexShrink={0}
bg={useColorModeValue("gray.100", "gray.700")}
px={1.5}
py={0.5}
borderRadius="md"
fontFamily="mono"
>
K
</Text>
</Flex>
{/* Dropdown Results */}
{isOpen && debouncedQuery.length >= 2 && (
<Box
position="absolute"
top="calc(100% + 8px)"
left={0}
right={0}
bg={bg}
border="1px solid"
borderColor={borderColor}
borderRadius="xl"
shadow="lg"
zIndex={100}
maxH="360px"
overflowY="auto"
py={2}
>
{isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="sm" color="primary.500" />
</Flex>
) : teams.length === 0 ? (
<Flex justify="center" py={6}>
<Text fontSize="sm" color="fg.muted">
Sonuç bulunamadı
</Text>
</Flex>
) : (
<VStack gap={0} align="stretch">
{teams.map((team: TeamDto) => (
<HStack
key={team.id}
px={3}
py={2.5}
cursor="pointer"
_hover={{ bg: hoverBg }}
transition="background 0.15s"
onClick={() => handleTeamClick(team)}
gap={3}
>
{team.logo ? (
<Image
src={team.logo}
alt={team.name}
boxSize="32px"
objectFit="contain"
borderRadius="md"
flexShrink={0}
/>
) : (
<Flex
boxSize="32px"
bg="primary.subtle"
borderRadius="md"
align="center"
justify="center"
flexShrink={0}
>
<Text fontSize="sm" fontWeight="bold" color="primary.fg">
{team.name?.charAt(0) || "T"}
</Text>
</Flex>
)}
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="600" truncate>
{team.name}
</Text>
{team.country && (
<Text fontSize="xs" color="fg.muted" truncate>
{team.country}
</Text>
)}
</Box>
</HStack>
))}
</VStack>
)}
</Box>
)}
</Box>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
Card,
VStack,
HStack,
Badge,
Spinner,
Button,
Table,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, StaggerContainer, StaggerItem } from "@/components/motion";
import {
useBulletins,
useGeneratePrediction,
useSyncBulletin,
useRolloverHistory,
} from "@/lib/api/spor-toto/use-hooks";
import type {
SporTotoBulletinDto,
SporTotoPredictionResultDto,
} from "@/lib/api/spor-toto/service";
import {
LuRefreshCw,
LuSparkles,
LuTrendingUp,
LuTicket,
} from "react-icons/lu";
import { useState } from "react";
import { toaster } from "@/components/ui/feedback/toaster";
import {
NativeSelectRoot,
NativeSelectField,
} from "@/components/ui/forms/native-select";
type PredictionStrategy =
| "CONSERVATIVE"
| "BALANCED"
| "AGGRESSIVE"
| "FORMULA_6PCT";
export default function SporTotoContent() {
const t = useTranslations("spor-toto");
const tCommon = useTranslations("common");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const [selectedBulletin, setSelectedBulletin] = useState<string>("");
const [strategy, setStrategy] = useState<PredictionStrategy>("BALANCED");
const bulletins = useBulletins();
const rolloverHistory = useRolloverHistory(10);
const syncBulletin = useSyncBulletin();
const generatePrediction = useGeneratePrediction();
const toast = (opts: { title: string; status: string }) =>
toaster.create({
title: opts.title,
type: opts.status as
| "success"
| "warning"
| "error"
| "info"
| "loading",
});
const handleSync = async () => {
await syncBulletin.mutateAsync();
toast({
title: t("sync-success"),
status: "success",
});
bulletins.refetch();
};
const handlePredict = async () => {
if (!selectedBulletin) {
toast({
title: t("select-bulletin"),
status: "warning",
});
return;
}
const result = await generatePrediction.mutateAsync({
bulletinId: selectedBulletin,
strategy,
});
toast({
title: t("prediction-generated"),
status: "success",
});
};
const strategies: {
value: PredictionStrategy;
label: string;
desc: string;
}[] = [
{
value: "CONSERVATIVE",
label: t("strategy-conservative"),
desc: t("strategy-conservative-desc"),
},
{
value: "BALANCED",
label: t("strategy-balanced"),
desc: t("strategy-balanced-desc"),
},
{
value: "AGGRESSIVE",
label: t("strategy-aggressive"),
desc: t("strategy-aggressive-desc"),
},
{
value: "FORMULA_6PCT",
label: t("strategy-formula"),
desc: t("strategy-formula-desc"),
},
];
return (
<SlideUp>
<Box maxW="6xl" mx="auto">
<Flex justify="space-between" align="center" mb={6}>
<Heading as="h1" size="xl" fontWeight="bold">
{t("title")}
</Heading>
<Button
variant="outline"
size="sm"
onClick={handleSync}
loading={syncBulletin.isPending}
>
<LuRefreshCw />
{t("sync-bulletins")}
</Button>
</Flex>
<StaggerContainer>
<Flex gap={6} direction={{ base: "column", lg: "row" }}>
{/* Left Column - Bulletin Selection & Prediction */}
<Box flex={2}>
{/* Bulletin Selection */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Header>
<Heading as="h3" size="sm">
{t("select-bulletin")}
</Heading>
</Card.Header>
<Card.Body pt={0}>
{bulletins.isLoading ? (
<Flex justify="center" py={4}>
<Spinner size="sm" color="primary.500" />
</Flex>
) : (
<NativeSelectRoot>
<NativeSelectField
value={selectedBulletin}
onChange={(e) => setSelectedBulletin(e.target.value)}
placeholder={t("choose-bulletin")}
>
{bulletins.data?.data?.map((b: SporTotoBulletinDto) => (
<option key={b.id} value={b.id}>
{t("bulletin-label", {
cycle: b.gameCycleNo,
date: new Date(b.drawDate).toLocaleDateString(),
})}
</option>
))}
</NativeSelectField>
</NativeSelectRoot>
)}
</Card.Body>
</Card.Root>
{/* Strategy Selection */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Header>
<Heading as="h3" size="sm">
{t("choose-strategy")}
</Heading>
</Card.Header>
<Card.Body pt={0}>
<VStack gap={3}>
{strategies.map((s) => (
<Card.Root
key={s.value}
borderWidth={strategy === s.value ? "2px" : "1px"}
borderColor={
strategy === s.value ? "primary.500" : borderColor
}
cursor="pointer"
onClick={() => setStrategy(s.value)}
_hover={{ borderColor: "primary.300" }}
>
<Card.Body py={3}>
<Flex justify="space-between" align="center">
<VStack align="start" gap={0}>
<Text fontWeight="semibold">{s.label}</Text>
<Text fontSize="xs" color="fg.muted">
{s.desc}
</Text>
</VStack>
<Badge
colorScheme={
strategy === s.value ? "primary" : "gray"
}
>
{strategy === s.value ? <LuSparkles /> : null}
{strategy === s.value ? t("selected") : ""}
</Badge>
</Flex>
</Card.Body>
</Card.Root>
))}
</VStack>
<Button
mt={4}
w="full"
onClick={handlePredict}
loading={generatePrediction.isPending}
>
<LuSparkles /> {t("generate-prediction")}
</Button>
</Card.Body>
</Card.Root>
{/* Bulletins List */}
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Header>
<Heading as="h3" size="sm">
{t("bulletin-history")}
</Heading>
</Card.Header>
<Card.Body pt={0}>
{bulletins.isLoading ? (
<Flex justify="center" py={6}>
<Spinner size="sm" color="primary.500" />
</Flex>
) : (
<Table.Root size="sm">
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
{t("cycle-no")}
</Table.ColumnHeader>
<Table.ColumnHeader>
{t("draw-date")}
</Table.ColumnHeader>
<Table.ColumnHeader>{t("status")}</Table.ColumnHeader>
<Table.ColumnHeader>
{t("matches")}
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{bulletins.data?.data?.map((b: SporTotoBulletinDto) => (
<Table.Row
key={b.id}
cursor="pointer"
onClick={() => setSelectedBulletin(b.id)}
bg={
selectedBulletin === b.id
? "primary.50"
: "transparent"
}
>
<Table.Cell>{b.gameCycleNo}</Table.Cell>
<Table.Cell>
{new Date(b.drawDate).toLocaleDateString()}
</Table.Cell>
<Table.Cell>
<Badge
colorScheme={
b.status === "COMPLETED"
? "green"
: b.status === "ACTIVE"
? "blue"
: "gray"
}
>
{b.status}
</Badge>
</Table.Cell>
<Table.Cell>{b.matches?.length || 0}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
</Card.Body>
</Card.Root>
</Box>
{/* Right Column - Rollover Stats */}
<Box flex={1}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Header>
<Heading as="h3" size="sm">
<HStack gap={2}>
<LuTrendingUp />
<Text>{t("rollover-stats")}</Text>
</HStack>
</Heading>
</Card.Header>
<Card.Body pt={0}>
{rolloverHistory.isLoading ? (
<Spinner size="sm" />
) : (
<VStack gap={3}>
{rolloverHistory.data?.data
?.slice(0, 5)
.map(
(item: {
gameCycleNo: number;
rolloverAmount: number;
drawDate: string;
}) => (
<Flex
key={item.gameCycleNo}
justify="space-between"
w="full"
py={2}
borderBottom="1px"
borderColor={borderColor}
>
<VStack align="start" gap={0}>
<Text fontSize="sm" fontWeight="semibold">
{t("cycle-no-short", {
cycle: item.gameCycleNo,
})}
</Text>
<Text fontSize="xs" color="fg.muted">
{new Date(item.drawDate).toLocaleDateString()}
</Text>
</VStack>
<Badge colorScheme="orange">
<HStack gap={1}>
<LuTicket />
<Text>
{new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
}).format(item.rolloverAmount)}
</Text>
</HStack>
</Badge>
</Flex>
),
)}
</VStack>
)}
</Card.Body>
</Card.Root>
</Box>
</Flex>
</StaggerContainer>
</Box>
</SlideUp>
);
}
@@ -0,0 +1,330 @@
"use client";
import {
Box,
Flex,
Text,
Heading,
Badge,
VStack,
HStack,
Image,
Spinner,
Button,
Card,
Table,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, FadeIn } from "@/components/motion";
import { useTeamById, useTeamMatches } from "@/lib/api/leagues/use-hooks";
import { LuArrowLeft, LuCalendar, LuTrophy } from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
function getMatchTimestamp(match: MatchResponseDto): number {
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
return Number.isFinite(raw) ? raw : 0;
}
function getMatchStatus(match: MatchResponseDto): string {
return String(match.status || (match as Record<string, unknown>).state || "").toUpperCase();
}
function isMatchFinished(match: MatchResponseDto): boolean {
const status = getMatchStatus(match);
return status === "FT" || status === "FINISHED" || status === "POSTGAME" || status === "POST_GAME";
}
function isMatchLive(match: MatchResponseDto): boolean {
const status = getMatchStatus(match);
return status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS";
}
function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
return String(team?.name || fallback || "");
}
function getTeamSideLogo(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
return String(team?.logo || team?.logoUrl || fallback || "");
}
function getLeagueLabel(match: MatchResponseDto): string {
return String(match.leagueName || match.league?.name || "");
}
export default function TeamDetailContent() {
const t = useTranslations();
const params = useParams();
const router = useRouter();
const teamId = params.id as string;
const { data: teamData, isLoading: teamLoading } = useTeamById(teamId);
const { data: matchesData, isLoading: matchesLoading } = useTeamMatches(teamId, { limit: 30 });
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const team = teamData?.data;
const matches: MatchResponseDto[] = matchesData?.data ?? [];
if (teamLoading) {
return (
<Flex justify="center" align="center" py={20}>
<Spinner size="lg" color="primary.500" />
</Flex>
);
}
if (!team) {
return (
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
<Button variant="outline" onClick={() => router.back()}>
<LuArrowLeft /> Geri
</Button>
</Flex>
);
}
// Separate past and upcoming matches
const isFinished = (m: MatchResponseDto) => isMatchFinished(m);
const pastMatches = matches.filter((m: MatchResponseDto) => isFinished(m));
const upcomingMatches = matches.filter((m: MatchResponseDto) => !isFinished(m));
const getStatusBadge = (match: MatchResponseDto) => {
if (isMatchLive(match))
return (
<Badge colorPalette="red" variant="subtle" fontSize="xs">
Canlı
</Badge>
);
if (isMatchFinished(match))
return (
<Badge colorPalette="gray" variant="subtle" fontSize="xs">
Bitti
</Badge>
);
return (
<Badge colorPalette="green" variant="subtle" fontSize="xs">
Yaklaşan
</Badge>
);
};
return (
<SlideUp>
<Box>
{/* Back Button */}
<Button variant="ghost" size="sm" mb={4} onClick={() => router.back()} gap={1.5}>
<LuArrowLeft />
Geri
</Button>
{/* Team Header */}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
<Card.Body>
<HStack gap={6} justify="center" align="center">
{team.logo ? (
<Image
src={team.logo}
alt={team.name}
boxSize="80px"
objectFit="contain"
/>
) : (
<Flex
boxSize="80px"
bg="primary.subtle"
borderRadius="xl"
align="center"
justify="center"
>
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
{team.name?.charAt(0) || "T"}
</Text>
</Flex>
)}
<VStack gap={1} align="start">
<Heading as="h1" size="xl">
{team.name}
</Heading>
{team.country && (
<Text fontSize="md" color="fg.muted">
🌍 {team.country}
</Text>
)}
<HStack gap={4} mt={1}>
<Badge colorPalette="blue" variant="subtle">
<LuTrophy style={{ width: 12, height: 12 }} />
{matches.length} Maç
</Badge>
<Badge colorPalette="green" variant="subtle">
<LuCalendar style={{ width: 12, height: 12 }} />
{upcomingMatches.length} Yaklaşan
</Badge>
</HStack>
</VStack>
</HStack>
</Card.Body>
</Card.Root>
{/* Upcoming Matches */}
{upcomingMatches.length > 0 && (
<FadeIn>
<Box mb={6}>
<Heading as="h2" size="lg" mb={4}>
📅 Yaklaşan Maçlar
</Heading>
<VStack gap={2} align="stretch">
{upcomingMatches.map((match: MatchResponseDto) => (
<MatchRow
key={match.id}
match={match}
cardBg={cardBg}
borderColor={borderColor}
statusBadge={getStatusBadge(match)}
onClick={() => router.push(`/tr/matches/${match.id}`)}
/>
))}
</VStack>
</Box>
</FadeIn>
)}
{/* Past Matches */}
<FadeIn>
<Box>
<Heading as="h2" size="lg" mb={4}>
📊 Geçmiş Maçlar
</Heading>
{matchesLoading ? (
<Flex justify="center" py={8}>
<Spinner size="md" color="primary.500" />
</Flex>
) : pastMatches.length === 0 ? (
<Text color="fg.muted" textAlign="center" py={8}>
Geçmiş maç bulunamadı
</Text>
) : (
<VStack gap={2} align="stretch">
{pastMatches.map((match: MatchResponseDto) => (
<MatchRow
key={match.id}
match={match}
cardBg={cardBg}
borderColor={borderColor}
statusBadge={getStatusBadge(match)}
onClick={() => router.push(`/tr/matches/${match.id}`)}
/>
))}
</VStack>
)}
</Box>
</FadeIn>
</Box>
</SlideUp>
);
}
// ─────────────────────────────────────────────────
// Match Row Component
// ─────────────────────────────────────────────────
interface MatchRowProps {
match: MatchResponseDto;
cardBg: string;
borderColor: string;
statusBadge: React.ReactNode;
onClick: () => void;
}
function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRowProps) {
const hoverBg = useColorModeValue("gray.50", "gray.700");
const matchTimestamp = getMatchTimestamp(match);
const homeTeamName = getTeamSideName(match.homeTeam, match.homeTeamName);
const awayTeamName = getTeamSideName(match.awayTeam, match.awayTeamName);
const homeTeamLogo = getTeamSideLogo(match.homeTeam, match.homeTeamLogo);
const awayTeamLogo = getTeamSideLogo(match.awayTeam, match.awayTeamLogo);
const leagueLabel = getLeagueLabel(match);
const hasScore = isMatchFinished(match) || isMatchLive(match);
return (
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="lg"
cursor="pointer"
transition="all 0.2s"
_hover={{ bg: hoverBg, transform: "translateY(-1px)", shadow: "sm" }}
onClick={onClick}
>
<Card.Body py={3} px={4}>
<Flex justify="space-between" align="center">
<HStack gap={3} flex={1}>
{/* Home Team */}
<HStack gap={2} flex={1} justify="flex-end">
<Text fontSize="sm" fontWeight="600" textAlign="right" truncate>
{homeTeamName}
</Text>
{homeTeamLogo ? (
<Image src={homeTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
) : (
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}>
<Text fontSize="xs" fontWeight="bold">{homeTeamName?.charAt(0)}</Text>
</Flex>
)}
</HStack>
{/* Score / VS */}
<VStack gap={0} flexShrink={0} minW="60px">
{hasScore && match.scoreHome !== undefined && match.scoreHome !== null ? (
<Text fontSize="md" fontWeight="900">
{match.scoreHome} - {match.scoreAway}
</Text>
) : (
<Text fontSize="sm" color="fg.muted" fontWeight="600">
vs
</Text>
)}
<Text fontSize="2xs" color="fg.muted">
{matchTimestamp
? new Date(matchTimestamp).toLocaleDateString("tr-TR", {
day: "2-digit",
month: "short",
})
: "-"}
</Text>
</VStack>
{/* Away Team */}
<HStack gap={2} flex={1}>
{awayTeamLogo ? (
<Image src={awayTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
) : (
<Flex boxSize="24px" bg="primary.subtle" borderRadius="full" align="center" justify="center" flexShrink={0}>
<Text fontSize="xs" fontWeight="bold">{awayTeamName?.charAt(0)}</Text>
</Flex>
)}
<Text fontSize="sm" fontWeight="600" truncate>
{awayTeamName}
</Text>
</HStack>
</HStack>
{/* Status + League */}
<HStack gap={2} flexShrink={0} ml={3}>
{leagueLabel && (
<Text fontSize="2xs" color="fg.muted" display={{ base: "none", md: "block" }}>
{leagueLabel}
</Text>
)}
{statusBadge}
</HStack>
</Flex>
</Card.Body>
</Card.Root>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import {
Box,
Flex,
Input,
Text,
VStack,
HStack,
Heading,
Image,
Spinner,
Card,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp } from "@/components/motion";
import { useSearchTeams } from "@/lib/api/leagues/use-hooks";
import { useRouter } from "@/i18n/navigation";
import { LuSearch } from "react-icons/lu";
import type { TeamDto } from "@/lib/api/leagues/types";
export default function TeamsContent() {
const t = useTranslations();
const [query, setQuery] = useState("");
const router = useRouter();
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const hoverBg = useColorModeValue("gray.50", "gray.700");
const { data: searchData, isLoading } = useSearchTeams({ q: query });
const teams: TeamDto[] = searchData?.data ?? [];
return (
<SlideUp>
<Box>
<Heading as="h1" size="xl" mb={6}>
🔍 {t("nav.teams")}
</Heading>
{/* Search Bar */}
<Flex
align="center"
bg={cardBg}
borderRadius="xl"
border="1px solid"
borderColor={borderColor}
px={4}
py={2}
mb={6}
gap={3}
>
<LuSearch style={{ opacity: 0.5, flexShrink: 0 }} />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Takım adı yazın... (min 2 karakter)"
variant="flushed"
size="lg"
fontSize="md"
/>
</Flex>
{/* Results */}
{isLoading ? (
<Flex justify="center" py={10}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : query.length < 2 ? (
<Flex justify="center" py={16} direction="column" align="center" gap={3}>
<Text fontSize="5xl"></Text>
<Text color="fg.muted" fontSize="lg">
Aramak istediğiniz takımın adını yazın
</Text>
<Text color="fg.muted" fontSize="sm">
Örnek: Galatasaray, Barcelona, Manchester City
</Text>
</Flex>
) : teams.length === 0 ? (
<Flex justify="center" py={10}>
<Text color="fg.muted">Sonuç bulunamadı</Text>
</Flex>
) : (
<VStack gap={3} align="stretch">
{teams.map((team: TeamDto) => (
<Card.Root
key={team.id}
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
cursor="pointer"
transition="all 0.2s"
_hover={{
bg: hoverBg,
transform: "translateY(-2px)",
shadow: "md",
}}
onClick={() => router.push(`/teams/${team.id}`)}
>
<Card.Body>
<HStack gap={4}>
{team.logo ? (
<Image
src={team.logo}
alt={team.name}
boxSize="48px"
objectFit="contain"
borderRadius="lg"
/>
) : (
<Flex
boxSize="48px"
bg="primary.subtle"
borderRadius="lg"
align="center"
justify="center"
>
<Text fontSize="xl" fontWeight="bold" color="primary.fg">
{team.name?.charAt(0) || "T"}
</Text>
</Flex>
)}
<Box flex={1}>
<Text fontSize="md" fontWeight="700">
{team.name}
</Text>
{team.country && (
<Text fontSize="sm" color="fg.muted">
{team.country}
</Text>
)}
</Box>
<Text fontSize="sm" color="fg.muted">
</Text>
</HStack>
</Card.Body>
</Card.Root>
))}
</VStack>
)}
</Box>
</SlideUp>
);
}
+53
View File
@@ -0,0 +1,53 @@
'use client';
import { useEffect, useState } from 'react';
import { Icon, IconButton, Presence } from '@chakra-ui/react';
import { FiChevronUp } from 'react-icons/fi';
const BackToTop = () => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsVisible(window.pageYOffset > 300);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<Presence
unmountOnExit
present={isVisible}
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
animationDuration='moderate'
>
<IconButton
variant={{ base: 'solid', _dark: 'subtle' }}
aria-label='Back to top'
position='fixed'
bottom='8'
right='8'
borderRadius='full'
size='lg'
shadow='lg'
zIndex='999'
onClick={scrollToTop}
>
<Icon>
<FiChevronUp />
</Icon>
</IconButton>
</Presence>
);
};
export default BackToTop;
+33
View File
@@ -0,0 +1,33 @@
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
import * as React from 'react';
interface ButtonLoadingProps {
loading?: boolean;
loadingText?: React.ReactNode;
}
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props;
return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? (
<>
<AbsoluteCenter display='inline-flex'>
<Spinner size='inherit' color='inherit' />
</AbsoluteCenter>
<Span opacity={0}>{children}</Span>
</>
) : loading && loadingText ? (
<>
<Spinner size='inherit' color='inherit' />
{loadingText}
</>
) : (
children
)}
</ChakraButton>
);
});
@@ -0,0 +1,14 @@
import type { ButtonProps } from '@chakra-ui/react';
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
import * as React from 'react';
import { LuX } from 'react-icons/lu';
export type CloseButtonProps = ButtonProps;
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
return (
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
);
});
+11
View File
@@ -0,0 +1,11 @@
'use client';
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
import { createRecipeContext } from '@chakra-ui/react';
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
const { withContext } = createRecipeContext({ key: 'button' });
// Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
+44
View File
@@ -0,0 +1,44 @@
'use client';
import type { ButtonProps } from '@chakra-ui/react';
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
import * as React from 'react';
interface ToggleProps extends ChakraToggle.RootProps {
variant?: keyof typeof variantMap;
size?: ButtonProps['size'];
}
const variantMap = {
solid: { on: 'solid', off: 'outline' },
surface: { on: 'surface', off: 'outline' },
subtle: { on: 'subtle', off: 'ghost' },
ghost: { on: 'subtle', off: 'ghost' },
} as const;
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
const { variant = 'subtle', size, children, ...rest } = props;
const variantConfig = variantMap[variant];
return (
<ChakraToggle.Root asChild {...rest}>
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
{children}
</ToggleBaseButton>
</ChakraToggle.Root>
);
});
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
variant: Record<'on' | 'off', ButtonProps['variant']>;
}
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
function ToggleBaseButton(props, ref) {
const toggle = useToggleContext();
const { variant, ...rest } = props;
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
},
);
export const ToggleIndicator = ChakraToggle.Indicator;
@@ -0,0 +1,91 @@
'use client';
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
import { CloseButton } from '@/components/ui/buttons/close-button';
import * as React from 'react';
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
clearable?: boolean;
}
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
function ComboboxControl(props, ref) {
const { children, clearable, ...rest } = props;
return (
<ChakraCombobox.Control {...rest} ref={ref}>
{children}
<ChakraCombobox.IndicatorGroup>
{clearable && <ComboboxClearTrigger />}
<ChakraCombobox.Trigger />
</ChakraCombobox.IndicatorGroup>
</ChakraCombobox.Control>
);
},
);
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
function ComboboxClearTrigger(props, ref) {
return (
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
</ChakraCombobox.ClearTrigger>
);
},
);
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>;
}
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
function ComboboxContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraCombobox.Positioner>
<ChakraCombobox.Content {...rest} ref={ref} />
</ChakraCombobox.Positioner>
</Portal>
);
},
);
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
function ComboboxItem(props, ref) {
const { item, children, ...rest } = props;
return (
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
{children}
<ChakraCombobox.ItemIndicator />
</ChakraCombobox.Item>
);
},
);
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
function ComboboxRoot(props, ref) {
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
},
) as ChakraCombobox.RootComponent;
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
label: React.ReactNode;
}
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
function ComboboxItemGroup(props, ref) {
const { children, label, ...rest } = props;
return (
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
{children}
</ChakraCombobox.ItemGroup>
);
},
);
export const ComboboxLabel = ChakraCombobox.Label;
export const ComboboxInput = ChakraCombobox.Input;
export const ComboboxEmpty = ChakraCombobox.Empty;
export const ComboboxItemText = ChakraCombobox.ItemText;
+28
View File
@@ -0,0 +1,28 @@
'use client';
import { Listbox as ChakraListbox } from '@chakra-ui/react';
import * as React from 'react';
export const ListboxRoot = React.forwardRef<HTMLDivElement, ChakraListbox.RootProps>(function ListboxRoot(props, ref) {
return <ChakraListbox.Root {...props} ref={ref} />;
}) as ChakraListbox.RootComponent;
export const ListboxContent = React.forwardRef<HTMLDivElement, ChakraListbox.ContentProps>(
function ListboxContent(props, ref) {
return <ChakraListbox.Content {...props} ref={ref} />;
},
);
export const ListboxItem = React.forwardRef<HTMLDivElement, ChakraListbox.ItemProps>(function ListboxItem(props, ref) {
const { children, ...rest } = props;
return (
<ChakraListbox.Item {...rest} ref={ref}>
{children}
<ChakraListbox.ItemIndicator />
</ChakraListbox.Item>
);
});
export const ListboxLabel = ChakraListbox.Label;
export const ListboxItemText = ChakraListbox.ItemText;
export const ListboxEmpty = ChakraListbox.Empty;
+118
View File
@@ -0,0 +1,118 @@
'use client';
import type { CollectionItem } from '@chakra-ui/react';
import { Select as ChakraSelect, Portal } from '@chakra-ui/react';
import { CloseButton } from '../buttons/close-button';
import * as React from 'react';
interface SelectTriggerProps extends ChakraSelect.ControlProps {
clearable?: boolean;
}
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
function SelectTrigger(props, ref) {
const { children, clearable, ...rest } = props;
return (
<ChakraSelect.Control {...rest}>
<ChakraSelect.Trigger ref={ref}>{children}</ChakraSelect.Trigger>
<ChakraSelect.IndicatorGroup>
{clearable && <SelectClearTrigger />}
<ChakraSelect.Indicator />
</ChakraSelect.IndicatorGroup>
</ChakraSelect.Control>
);
},
);
const SelectClearTrigger = React.forwardRef<HTMLButtonElement, ChakraSelect.ClearTriggerProps>(
function SelectClearTrigger(props, ref) {
return (
<ChakraSelect.ClearTrigger asChild {...props} ref={ref}>
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
</ChakraSelect.ClearTrigger>
);
},
);
interface SelectContentProps extends ChakraSelect.ContentProps {
portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>;
}
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(function SelectContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraSelect.Positioner>
<ChakraSelect.Content {...rest} ref={ref} />
</ChakraSelect.Positioner>
</Portal>
);
});
export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProps>(function SelectItem(props, ref) {
const { item, children, ...rest } = props;
return (
<ChakraSelect.Item key={item.value} item={item} {...rest} ref={ref}>
{children}
<ChakraSelect.ItemIndicator />
</ChakraSelect.Item>
);
});
interface SelectValueTextProps extends Omit<ChakraSelect.ValueTextProps, 'children'> {
children?(items: CollectionItem[]): React.ReactNode;
}
export const SelectValueText = React.forwardRef<HTMLSpanElement, SelectValueTextProps>(
function SelectValueText(props, ref) {
const { children, ...rest } = props;
return (
<ChakraSelect.ValueText {...rest} ref={ref}>
<ChakraSelect.Context>
{(select) => {
const items = select.selectedItems;
if (items.length === 0) return props.placeholder;
if (children) return children(items);
if (items.length === 1) return select.collection.stringifyItem(items[0]);
return `${items.length} selected`;
}}
</ChakraSelect.Context>
</ChakraSelect.ValueText>
);
},
);
export const SelectRoot = React.forwardRef<HTMLDivElement, ChakraSelect.RootProps>(function SelectRoot(props, ref) {
return (
<ChakraSelect.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }}>
{props.asChild ? (
props.children
) : (
<>
<ChakraSelect.HiddenSelect />
{props.children}
</>
)}
</ChakraSelect.Root>
);
}) as ChakraSelect.RootComponent;
interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps {
label: React.ReactNode;
}
export const SelectItemGroup = React.forwardRef<HTMLDivElement, SelectItemGroupProps>(
function SelectItemGroup(props, ref) {
const { children, label, ...rest } = props;
return (
<ChakraSelect.ItemGroup {...rest} ref={ref}>
<ChakraSelect.ItemGroupLabel>{label}</ChakraSelect.ItemGroupLabel>
{children}
</ChakraSelect.ItemGroup>
);
},
);
export const SelectLabel = ChakraSelect.Label;
export const SelectItemText = ChakraSelect.ItemText;
@@ -0,0 +1,60 @@
'use client';
import { TreeView as ChakraTreeView } from '@chakra-ui/react';
import * as React from 'react';
export const TreeViewRoot = React.forwardRef<HTMLDivElement, ChakraTreeView.RootProps>(
function TreeViewRoot(props, ref) {
return <ChakraTreeView.Root {...props} ref={ref} />;
},
);
interface TreeViewTreeProps extends ChakraTreeView.TreeProps {}
export const TreeViewTree = React.forwardRef<HTMLDivElement, TreeViewTreeProps>(function TreeViewTree(props, ref) {
const { ...rest } = props;
return <ChakraTreeView.Tree {...rest} ref={ref} />;
});
export const TreeViewBranch = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchProps>(
function TreeViewBranch(props, ref) {
return <ChakraTreeView.Branch {...props} ref={ref} />;
},
);
export const TreeViewBranchControl = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchControlProps>(
function TreeViewBranchControl(props, ref) {
return <ChakraTreeView.BranchControl {...props} ref={ref} />;
},
);
export const TreeViewItem = React.forwardRef<HTMLDivElement, ChakraTreeView.ItemProps>(
function TreeViewItem(props, ref) {
return <ChakraTreeView.Item {...props} ref={ref} />;
},
);
export const TreeViewLabel = ChakraTreeView.Label;
export const TreeViewBranchIndicator = ChakraTreeView.BranchIndicator;
export const TreeViewBranchText = ChakraTreeView.BranchText;
export const TreeViewBranchContent = ChakraTreeView.BranchContent;
export const TreeViewBranchIndentGuide = ChakraTreeView.BranchIndentGuide;
export const TreeViewItemText = ChakraTreeView.ItemText;
export const TreeViewNode = ChakraTreeView.Node;
export const TreeViewNodeProvider = ChakraTreeView.NodeProvider;
export const TreeView = {
Root: TreeViewRoot,
Label: TreeViewLabel,
Tree: TreeViewTree,
Branch: TreeViewBranch,
BranchControl: TreeViewBranchControl,
BranchIndicator: TreeViewBranchIndicator,
BranchText: TreeViewBranchText,
BranchContent: TreeViewBranchContent,
BranchIndentGuide: TreeViewBranchIndentGuide,
Item: TreeViewItem,
ItemText: TreeViewItemText,
Node: TreeViewNode,
NodeProvider: TreeViewNodeProvider,
};
+108
View File
@@ -0,0 +1,108 @@
'use client';
import type { IconButtonProps, SpanProps } from '@chakra-ui/react';
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react';
import { ThemeProvider, useTheme } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';
import * as React from 'react';
import { LuMoon, LuSun } from 'react-icons/lu';
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return <ThemeProvider attribute='class' disableTransitionOnChange {...props} />;
}
export type ColorMode = 'light' | 'dark';
export interface UseColorModeReturn {
colorMode: ColorMode;
setColorMode: (colorMode: ColorMode) => void;
toggleColorMode: () => void;
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
const colorMode = forcedTheme || resolvedTheme;
const toggleColorMode = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
};
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (!mounted) {
return light;
}
return colorMode === 'dark' ? dark : light;
}
export function ColorModeIcon() {
const { colorMode } = useColorMode();
return colorMode === 'dark' ? <LuMoon /> : <LuSun />;
}
interface ColorModeButtonProps extends Omit<IconButtonProps, 'aria-label'> {}
export const ColorModeButton = React.forwardRef<HTMLButtonElement, ColorModeButtonProps>(
function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode();
return (
<ClientOnly fallback={<Skeleton boxSize='9' />}>
<IconButton
onClick={toggleColorMode}
variant='ghost'
aria-label='Toggle color mode'
size='sm'
ref={ref}
{...props}
css={{
_icon: {
width: '5',
height: '5',
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
);
},
);
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(function LightMode(props, ref) {
return (
<Span
color='fg'
display='contents'
className='chakra-theme light'
colorPalette='gray'
colorScheme='light'
ref={ref}
{...props}
/>
);
});
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(function DarkMode(props, ref) {
return (
<Span
color='fg'
display='contents'
className='chakra-theme dark'
colorPalette='gray'
colorScheme='dark'
ref={ref}
{...props}
/>
);
});
+26
View File
@@ -0,0 +1,26 @@
import { Avatar as ChakraAvatar, AvatarGroup as ChakraAvatarGroup } from '@chakra-ui/react';
import * as React from 'react';
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
export interface AvatarProps extends ChakraAvatar.RootProps {
name?: string;
src?: string;
srcSet?: string;
loading?: ImageProps['loading'];
icon?: React.ReactElement;
fallback?: React.ReactNode;
}
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar(props, ref) {
const { name, src, srcSet, loading, icon, fallback, children, ...rest } = props;
return (
<ChakraAvatar.Root ref={ref} {...rest}>
<ChakraAvatar.Fallback name={name}>{icon || fallback}</ChakraAvatar.Fallback>
<ChakraAvatar.Image src={src} srcSet={srcSet} loading={loading} />
{children}
</ChakraAvatar.Root>
);
});
export const AvatarGroup = ChakraAvatarGroup;
@@ -0,0 +1,79 @@
import type { ButtonProps, InputProps } from '@chakra-ui/react';
import { Button, Clipboard as ChakraClipboard, IconButton, Input } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck, LuClipboard, LuLink } from 'react-icons/lu';
const ClipboardIcon = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
function ClipboardIcon(props, ref) {
return (
<ChakraClipboard.Indicator copied={<LuCheck />} {...props} ref={ref}>
<LuClipboard />
</ChakraClipboard.Indicator>
);
},
);
const ClipboardCopyText = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
function ClipboardCopyText(props, ref) {
return (
<ChakraClipboard.Indicator copied='Copied' {...props} ref={ref}>
Copy
</ChakraClipboard.Indicator>
);
},
);
export const ClipboardLabel = React.forwardRef<HTMLLabelElement, ChakraClipboard.LabelProps>(
function ClipboardLabel(props, ref) {
return (
<ChakraClipboard.Label textStyle='sm' fontWeight='medium' display='inline-block' mb='1' {...props} ref={ref} />
);
},
);
export const ClipboardButton = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardButton(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<Button ref={ref} size='sm' variant='surface' {...props}>
<ClipboardIcon />
<ClipboardCopyText />
</Button>
</ChakraClipboard.Trigger>
);
});
export const ClipboardLink = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardLink(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<Button unstyled variant='plain' size='xs' display='inline-flex' alignItems='center' gap='2' ref={ref} {...props}>
<LuLink />
<ClipboardCopyText />
</Button>
</ChakraClipboard.Trigger>
);
});
export const ClipboardIconButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ClipboardIconButton(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<IconButton ref={ref} size='xs' variant='subtle' {...props}>
<ClipboardIcon />
<ClipboardCopyText srOnly />
</IconButton>
</ChakraClipboard.Trigger>
);
},
);
export const ClipboardInput = React.forwardRef<HTMLInputElement, InputProps>(
function ClipboardInputElement(props, ref) {
return (
<ChakraClipboard.Input asChild>
<Input ref={ref} {...props} />
</ChakraClipboard.Input>
);
},
);
export const ClipboardRoot = ChakraClipboard.Root;
@@ -0,0 +1,26 @@
import { DataList as ChakraDataList } from '@chakra-ui/react';
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
import * as React from 'react';
export const DataListRoot = ChakraDataList.Root;
interface ItemProps extends ChakraDataList.ItemProps {
label: React.ReactNode;
value: React.ReactNode;
info?: React.ReactNode;
grow?: boolean;
}
export const DataListItem = React.forwardRef<HTMLDivElement, ItemProps>(function DataListItem(props, ref) {
const { label, info, value, children, grow, ...rest } = props;
return (
<ChakraDataList.Item ref={ref} {...rest}>
<ChakraDataList.ItemLabel flex={grow ? '1' : undefined}>
{label}
{info && <InfoTip>{info}</InfoTip>}
</ChakraDataList.ItemLabel>
<ChakraDataList.ItemValue flex={grow ? '1' : undefined}>{value}</ChakraDataList.ItemValue>
{children}
</ChakraDataList.Item>
);
});
@@ -0,0 +1,20 @@
import { QrCode as ChakraQrCode } from '@chakra-ui/react';
import * as React from 'react';
export interface QrCodeProps extends Omit<ChakraQrCode.RootProps, 'fill' | 'overlay'> {
fill?: string;
overlay?: React.ReactNode;
}
export const QrCode = React.forwardRef<HTMLDivElement, QrCodeProps>(function QrCode(props, ref) {
const { children, fill, overlay, ...rest } = props;
return (
<ChakraQrCode.Root ref={ref} {...rest}>
<ChakraQrCode.Frame style={{ fill }}>
<ChakraQrCode.Pattern />
</ChakraQrCode.Frame>
{children}
{overlay && <ChakraQrCode.Overlay>{overlay}</ChakraQrCode.Overlay>}
</ChakraQrCode.Root>
);
});
+53
View File
@@ -0,0 +1,53 @@
import { Badge, type BadgeProps, Stat as ChakraStat, FormatNumber } from '@chakra-ui/react';
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
import * as React from 'react';
interface StatLabelProps extends ChakraStat.LabelProps {
info?: React.ReactNode;
}
export const StatLabel = React.forwardRef<HTMLDivElement, StatLabelProps>(function StatLabel(props, ref) {
const { info, children, ...rest } = props;
return (
<ChakraStat.Label {...rest} ref={ref}>
{children}
{info && <InfoTip>{info}</InfoTip>}
</ChakraStat.Label>
);
});
interface StatValueTextProps extends ChakraStat.ValueTextProps {
value?: number;
formatOptions?: Intl.NumberFormatOptions;
}
export const StatValueText = React.forwardRef<HTMLDivElement, StatValueTextProps>(function StatValueText(props, ref) {
const { value, formatOptions, children, ...rest } = props;
return (
<ChakraStat.ValueText {...rest} ref={ref}>
{children || (value != null && <FormatNumber value={value} {...formatOptions} />)}
</ChakraStat.ValueText>
);
});
export const StatUpTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatUpTrend(props, ref) {
return (
<Badge colorPalette='green' gap='0' {...props} ref={ref}>
<ChakraStat.UpIndicator />
{props.children}
</Badge>
);
});
export const StatDownTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatDownTrend(props, ref) {
return (
<Badge colorPalette='red' gap='0' {...props} ref={ref}>
<ChakraStat.DownIndicator />
{props.children}
</Badge>
);
});
export const StatRoot = ChakraStat.Root;
export const StatHelpText = ChakraStat.HelpText;
export const StatValueUnit = ChakraStat.ValueUnit;
+26
View File
@@ -0,0 +1,26 @@
import { Tag as ChakraTag } from '@chakra-ui/react';
import * as React from 'react';
export interface TagProps extends ChakraTag.RootProps {
startElement?: React.ReactNode;
endElement?: React.ReactNode;
onClose?: VoidFunction;
closable?: boolean;
}
export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(function Tag(props, ref) {
const { startElement, endElement, onClose, closable = !!onClose, children, ...rest } = props;
return (
<ChakraTag.Root ref={ref} {...rest}>
{startElement && <ChakraTag.StartElement>{startElement}</ChakraTag.StartElement>}
<ChakraTag.Label>{children}</ChakraTag.Label>
{endElement && <ChakraTag.EndElement>{endElement}</ChakraTag.EndElement>}
{closable && (
<ChakraTag.EndElement>
<ChakraTag.CloseTrigger onClick={onClose} />
</ChakraTag.EndElement>
)}
</ChakraTag.Root>
);
});
@@ -0,0 +1,25 @@
import { Timeline as ChakraTimeline } from '@chakra-ui/react';
import * as React from 'react';
interface TimelineConnectorProps extends ChakraTimeline.IndicatorProps {
icon?: React.ReactNode;
}
export const TimelineConnector = React.forwardRef<HTMLDivElement, TimelineConnectorProps>(function TimelineConnector(
{ icon, ...props },
ref,
) {
return (
<ChakraTimeline.Connector ref={ref}>
<ChakraTimeline.Separator />
<ChakraTimeline.Indicator {...props}>{icon}</ChakraTimeline.Indicator>
</ChakraTimeline.Connector>
);
});
export const TimelineRoot = ChakraTimeline.Root;
export const TimelineContent = ChakraTimeline.Content;
export const TimelineItem = ChakraTimeline.Item;
export const TimelineIndicator = ChakraTimeline.Indicator;
export const TimelineTitle = ChakraTimeline.Title;
export const TimelineDescription = ChakraTimeline.Description;
@@ -0,0 +1,45 @@
import { Accordion, HStack } from '@chakra-ui/react';
import * as React from 'react';
import { LuChevronDown } from 'react-icons/lu';
interface AccordionItemTriggerProps extends Accordion.ItemTriggerProps {
indicatorPlacement?: 'start' | 'end';
}
export const AccordionItemTrigger = React.forwardRef<HTMLButtonElement, AccordionItemTriggerProps>(
function AccordionItemTrigger(props, ref) {
const { children, indicatorPlacement = 'end', ...rest } = props;
return (
<Accordion.ItemTrigger {...rest} ref={ref}>
{indicatorPlacement === 'start' && (
<Accordion.ItemIndicator rotate={{ base: '-90deg', _open: '0deg' }}>
<LuChevronDown />
</Accordion.ItemIndicator>
)}
<HStack gap='4' flex='1' textAlign='start' width='full'>
{children}
</HStack>
{indicatorPlacement === 'end' && (
<Accordion.ItemIndicator>
<LuChevronDown />
</Accordion.ItemIndicator>
)}
</Accordion.ItemTrigger>
);
},
);
interface AccordionItemContentProps extends Accordion.ItemContentProps {}
export const AccordionItemContent = React.forwardRef<HTMLDivElement, AccordionItemContentProps>(
function AccordionItemContent(props, ref) {
return (
<Accordion.ItemContent>
<Accordion.ItemBody {...props} ref={ref} />
</Accordion.ItemContent>
);
},
);
export const AccordionRoot = Accordion.Root;
export const AccordionItem = Accordion.Item;
@@ -0,0 +1,35 @@
import { Breadcrumb, type SystemStyleObject } from '@chakra-ui/react';
import * as React from 'react';
export interface BreadcrumbRootProps extends Breadcrumb.RootProps {
separator?: React.ReactNode;
separatorGap?: SystemStyleObject['gap'];
}
export const BreadcrumbRoot = React.forwardRef<HTMLDivElement, BreadcrumbRootProps>(
function BreadcrumbRoot(props, ref) {
const { separator, separatorGap, children, ...rest } = props;
const validChildren = React.Children.toArray(children).filter(React.isValidElement);
return (
<Breadcrumb.Root ref={ref} {...rest}>
<Breadcrumb.List gap={separatorGap}>
{validChildren.map((child, index) => {
const last = index === validChildren.length - 1;
return (
<React.Fragment key={index}>
<Breadcrumb.Item>{child}</Breadcrumb.Item>
{!last && <Breadcrumb.Separator>{separator}</Breadcrumb.Separator>}
</React.Fragment>
);
})}
</Breadcrumb.List>
</Breadcrumb.Root>
);
},
);
export const BreadcrumbLink = Breadcrumb.Link;
export const BreadcrumbCurrentLink = Breadcrumb.CurrentLink;
export const BreadcrumbEllipsis = Breadcrumb.Ellipsis;
+182
View File
@@ -0,0 +1,182 @@
'use client';
import type { ButtonProps, TextProps } from '@chakra-ui/react';
import {
Button,
Pagination as ChakraPagination,
IconButton,
Text,
createContext,
usePaginationContext,
} from '@chakra-ui/react';
import * as React from 'react';
import { HiChevronLeft, HiChevronRight, HiMiniEllipsisHorizontal } from 'react-icons/hi2';
import { LinkButton } from '@/components/ui/buttons/link-button';
interface ButtonVariantMap {
current: ButtonProps['variant'];
default: ButtonProps['variant'];
ellipsis: ButtonProps['variant'];
}
type PaginationVariant = 'outline' | 'solid' | 'subtle';
interface ButtonVariantContext {
size: ButtonProps['size'];
variantMap: ButtonVariantMap;
getHref?: (page: number) => string;
}
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
name: 'RootPropsProvider',
});
export interface PaginationRootProps extends Omit<ChakraPagination.RootProps, 'type'> {
size?: ButtonProps['size'];
variant?: PaginationVariant;
getHref?: (page: number) => string;
}
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
outline: { default: 'ghost', ellipsis: 'plain', current: 'outline' },
solid: { default: 'outline', ellipsis: 'outline', current: 'solid' },
subtle: { default: 'ghost', ellipsis: 'plain', current: 'subtle' },
};
export const PaginationRoot = React.forwardRef<HTMLDivElement, PaginationRootProps>(
function PaginationRoot(props, ref) {
const { size = 'sm', variant = 'outline', getHref, ...rest } = props;
return (
<RootPropsProvider value={{ size, variantMap: variantMap[variant], getHref }}>
<ChakraPagination.Root ref={ref} type={getHref ? 'link' : 'button'} {...rest} />
</RootPropsProvider>
);
},
);
export const PaginationEllipsis = React.forwardRef<HTMLDivElement, ChakraPagination.EllipsisProps>(
function PaginationEllipsis(props, ref) {
const { size, variantMap } = useRootProps();
return (
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
<Button as='span' variant={variantMap.ellipsis} size={size}>
<HiMiniEllipsisHorizontal />
</Button>
</ChakraPagination.Ellipsis>
);
},
);
export const PaginationItem = React.forwardRef<HTMLButtonElement, ChakraPagination.ItemProps>(
function PaginationItem(props, ref) {
const { page } = usePaginationContext();
const { size, variantMap, getHref } = useRootProps();
const current = page === props.value;
const variant = current ? variantMap.current : variantMap.default;
if (getHref) {
return (
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
{props.value}
</LinkButton>
);
}
return (
<ChakraPagination.Item ref={ref} {...props} asChild>
<Button variant={variant} size={size}>
{props.value}
</Button>
</ChakraPagination.Item>
);
},
);
export const PaginationPrevTrigger = React.forwardRef<HTMLButtonElement, ChakraPagination.PrevTriggerProps>(
function PaginationPrevTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps();
const { previousPage } = usePaginationContext();
if (getHref) {
return (
<LinkButton
href={previousPage != null ? getHref(previousPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronLeft />
</LinkButton>
);
}
return (
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronLeft />
</IconButton>
</ChakraPagination.PrevTrigger>
);
},
);
export const PaginationNextTrigger = React.forwardRef<HTMLButtonElement, ChakraPagination.NextTriggerProps>(
function PaginationNextTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps();
const { nextPage } = usePaginationContext();
if (getHref) {
return (
<LinkButton href={nextPage != null ? getHref(nextPage) : undefined} variant={variantMap.default} size={size}>
<HiChevronRight />
</LinkButton>
);
}
return (
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronRight />
</IconButton>
</ChakraPagination.NextTrigger>
);
},
);
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
return (
<ChakraPagination.Context>
{({ pages }) =>
pages.map((page, index) => {
return page.type === 'ellipsis' ? (
<PaginationEllipsis key={index} index={index} {...props} />
) : (
<PaginationItem key={index} type='page' value={page.value} {...props} />
);
})
}
</ChakraPagination.Context>
);
};
interface PageTextProps extends TextProps {
format?: 'short' | 'compact' | 'long';
}
export const PaginationPageText = React.forwardRef<HTMLParagraphElement, PageTextProps>(
function PaginationPageText(props, ref) {
const { format = 'compact', ...rest } = props;
const { page, totalPages, pageRange, count } = usePaginationContext();
const content = React.useMemo(() => {
if (format === 'short') return `${page} / ${totalPages}`;
if (format === 'compact') return `${page} of ${totalPages}`;
return `${pageRange.start + 1} - ${Math.min(pageRange.end, count)} of ${count}`;
}, [format, page, totalPages, pageRange, count]);
return (
<Text fontWeight='medium' ref={ref} {...rest}>
{content}
</Text>
);
},
);
+73
View File
@@ -0,0 +1,73 @@
import { Box, Steps as ChakraSteps } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck } from 'react-icons/lu';
interface StepInfoProps {
title?: React.ReactNode;
description?: React.ReactNode;
}
export interface StepsItemProps extends Omit<ChakraSteps.ItemProps, 'title'>, StepInfoProps {
completedIcon?: React.ReactNode;
icon?: React.ReactNode;
disableTrigger?: boolean;
}
export const StepsItem = React.forwardRef<HTMLDivElement, StepsItemProps>(function StepsItem(props, ref) {
const { title, description, completedIcon, icon, disableTrigger, ...rest } = props;
return (
<ChakraSteps.Item {...rest} ref={ref}>
<ChakraSteps.Trigger disabled={disableTrigger}>
<ChakraSteps.Indicator>
<ChakraSteps.Status complete={completedIcon || <LuCheck />} incomplete={icon || <ChakraSteps.Number />} />
</ChakraSteps.Indicator>
<StepInfo title={title} description={description} />
</ChakraSteps.Trigger>
<ChakraSteps.Separator />
</ChakraSteps.Item>
);
});
const StepInfo = (props: StepInfoProps) => {
const { title, description } = props;
if (title && description) {
return (
<Box>
<ChakraSteps.Title>{title}</ChakraSteps.Title>
<ChakraSteps.Description>{description}</ChakraSteps.Description>
</Box>
);
}
return (
<>
{title && <ChakraSteps.Title>{title}</ChakraSteps.Title>}
{description && <ChakraSteps.Description>{description}</ChakraSteps.Description>}
</>
);
};
interface StepsIndicatorProps {
completedIcon: React.ReactNode;
icon?: React.ReactNode;
}
export const StepsIndicator = React.forwardRef<HTMLDivElement, StepsIndicatorProps>(
function StepsIndicator(props, ref) {
const { icon = <ChakraSteps.Number />, completedIcon } = props;
return (
<ChakraSteps.Indicator ref={ref}>
<ChakraSteps.Status complete={completedIcon} incomplete={icon} />
</ChakraSteps.Indicator>
);
},
);
export const StepsList = ChakraSteps.List;
export const StepsRoot = ChakraSteps.Root;
export const StepsContent = ChakraSteps.Content;
export const StepsCompletedContent = ChakraSteps.CompletedContent;
export const StepsNextTrigger = ChakraSteps.NextTrigger;
export const StepsPrevTrigger = ChakraSteps.PrevTrigger;
+27
View File
@@ -0,0 +1,27 @@
import { Alert as ChakraAlert } from '@chakra-ui/react';
import * as React from 'react';
export interface AlertProps extends Omit<ChakraAlert.RootProps, 'title'> {
startElement?: React.ReactNode;
endElement?: React.ReactNode;
title?: React.ReactNode;
icon?: React.ReactElement;
}
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(props, ref) {
const { title, children, icon, startElement, endElement, ...rest } = props;
return (
<ChakraAlert.Root ref={ref} {...rest}>
{startElement || <ChakraAlert.Indicator>{icon}</ChakraAlert.Indicator>}
{children ? (
<ChakraAlert.Content>
<ChakraAlert.Title>{title}</ChakraAlert.Title>
<ChakraAlert.Description>{children}</ChakraAlert.Description>
</ChakraAlert.Content>
) : (
<ChakraAlert.Title flex='1'>{title}</ChakraAlert.Title>
)}
{endElement}
</ChakraAlert.Root>
);
});
@@ -0,0 +1,28 @@
import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react';
import * as React from 'react';
export interface EmptyStateProps extends ChakraEmptyState.RootProps {
title: string;
description?: string;
icon?: React.ReactNode;
}
export const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(props, ref) {
const { title, description, icon, children, ...rest } = props;
return (
<ChakraEmptyState.Root ref={ref} {...rest}>
<ChakraEmptyState.Content>
{icon && <ChakraEmptyState.Indicator>{icon}</ChakraEmptyState.Indicator>}
{description ? (
<VStack textAlign='center'>
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
<ChakraEmptyState.Description>{description}</ChakraEmptyState.Description>
</VStack>
) : (
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
)}
{children}
</ChakraEmptyState.Content>
</ChakraEmptyState.Root>
);
});
@@ -0,0 +1,32 @@
import type { SystemStyleObject } from '@chakra-ui/react';
import { AbsoluteCenter, ProgressCircle as ChakraProgressCircle } from '@chakra-ui/react';
import * as React from 'react';
interface ProgressCircleRingProps extends ChakraProgressCircle.CircleProps {
trackColor?: SystemStyleObject['stroke'];
cap?: SystemStyleObject['strokeLinecap'];
}
export const ProgressCircleRing = React.forwardRef<SVGSVGElement, ProgressCircleRingProps>(
function ProgressCircleRing(props, ref) {
const { trackColor, cap, color, ...rest } = props;
return (
<ChakraProgressCircle.Circle {...rest} ref={ref}>
<ChakraProgressCircle.Track stroke={trackColor} />
<ChakraProgressCircle.Range stroke={color} strokeLinecap={cap} />
</ChakraProgressCircle.Circle>
);
},
);
export const ProgressCircleValueText = React.forwardRef<HTMLDivElement, ChakraProgressCircle.ValueTextProps>(
function ProgressCircleValueText(props, ref) {
return (
<AbsoluteCenter>
<ChakraProgressCircle.ValueText {...props} ref={ref} />
</AbsoluteCenter>
);
},
);
export const ProgressCircleRoot = ChakraProgressCircle.Root;
+30
View File
@@ -0,0 +1,30 @@
import { Progress as ChakraProgress } from '@chakra-ui/react';
import { InfoTip } from '../overlays/toggle-tip';
import * as React from 'react';
export const ProgressBar = React.forwardRef<HTMLDivElement, ChakraProgress.TrackProps>(
function ProgressBar(props, ref) {
return (
<ChakraProgress.Track {...props} ref={ref}>
<ChakraProgress.Range />
</ChakraProgress.Track>
);
},
);
export interface ProgressLabelProps extends ChakraProgress.LabelProps {
info?: React.ReactNode;
}
export const ProgressLabel = React.forwardRef<HTMLDivElement, ProgressLabelProps>(function ProgressLabel(props, ref) {
const { children, info, ...rest } = props;
return (
<ChakraProgress.Label {...rest} ref={ref}>
{children}
{info && <InfoTip>{info}</InfoTip>}
</ChakraProgress.Label>
);
});
export const ProgressRoot = ChakraProgress.Root;
export const ProgressValueText = ChakraProgress.ValueText;
+35
View File
@@ -0,0 +1,35 @@
import type { SkeletonProps as ChakraSkeletonProps, CircleProps } from '@chakra-ui/react';
import { Skeleton as ChakraSkeleton, Circle, Stack } from '@chakra-ui/react';
import * as React from 'react';
export interface SkeletonCircleProps extends ChakraSkeletonProps {
size?: CircleProps['size'];
}
export const SkeletonCircle = React.forwardRef<HTMLDivElement, SkeletonCircleProps>(
function SkeletonCircle(props, ref) {
const { size, ...rest } = props;
return (
<Circle size={size} asChild ref={ref}>
<ChakraSkeleton {...rest} />
</Circle>
);
},
);
export interface SkeletonTextProps extends ChakraSkeletonProps {
noOfLines?: number;
}
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(function SkeletonText(props, ref) {
const { noOfLines = 3, gap, ...rest } = props;
return (
<Stack gap={gap} width='full' ref={ref}>
{Array.from({ length: noOfLines }).map((_, index) => (
<ChakraSkeleton height='4' key={index} {...props} _last={{ maxW: '80%' }} {...rest} />
))}
</Stack>
);
});
export const Skeleton = ChakraSkeleton;
+27
View File
@@ -0,0 +1,27 @@
import type { ColorPalette } from '@chakra-ui/react';
import { Status as ChakraStatus } from '@chakra-ui/react';
import * as React from 'react';
type StatusValue = 'success' | 'error' | 'warning' | 'info';
export interface StatusProps extends ChakraStatus.RootProps {
value?: StatusValue;
}
const statusMap: Record<StatusValue, ColorPalette> = {
success: 'green',
error: 'red',
warning: 'orange',
info: 'blue',
};
export const Status = React.forwardRef<HTMLDivElement, StatusProps>(function Status(props, ref) {
const { children, value = 'info', ...rest } = props;
const colorPalette = rest.colorPalette ?? statusMap[value];
return (
<ChakraStatus.Root ref={ref} {...rest} colorPalette={colorPalette}>
<ChakraStatus.Indicator />
{children}
</ChakraStatus.Root>
);
});
+28
View File
@@ -0,0 +1,28 @@
'use client';
import { Toaster as ChakraToaster, Portal, Spinner, Stack, Toast, createToaster } from '@chakra-ui/react';
export const toaster = createToaster({
placement: 'bottom-end',
pauseOnPageIdle: true,
});
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: '4' }}>
{(toast) => (
<Toast.Root width={{ md: 'sm' }}>
{toast.type === 'loading' ? <Spinner size='sm' color='blue.solid' /> : <Toast.Indicator />}
<Stack gap='1' flex='1' maxWidth='100%'>
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && <Toast.Description>{toast.description}</Toast.Description>}
</Stack>
{toast.action && <Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
);
};
+49
View File
@@ -0,0 +1,49 @@
import { CheckboxCard as ChakraCheckboxCard } from '@chakra-ui/react';
import * as React from 'react';
export interface CheckboxCardProps extends ChakraCheckboxCard.RootProps {
icon?: React.ReactElement;
label?: React.ReactNode;
description?: React.ReactNode;
addon?: React.ReactNode;
indicator?: React.ReactNode | null;
indicatorPlacement?: 'start' | 'end' | 'inside';
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const CheckboxCard = React.forwardRef<HTMLInputElement, CheckboxCardProps>(function CheckboxCard(props, ref) {
const {
inputProps,
label,
description,
icon,
addon,
indicator = <ChakraCheckboxCard.Indicator />,
indicatorPlacement = 'end',
...rest
} = props;
const hasContent = label || description || icon;
const ContentWrapper = indicator ? ChakraCheckboxCard.Content : React.Fragment;
return (
<ChakraCheckboxCard.Root {...rest}>
<ChakraCheckboxCard.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckboxCard.Control>
{indicatorPlacement === 'start' && indicator}
{hasContent && (
<ContentWrapper>
{icon}
{label && <ChakraCheckboxCard.Label>{label}</ChakraCheckboxCard.Label>}
{description && <ChakraCheckboxCard.Description>{description}</ChakraCheckboxCard.Description>}
{indicatorPlacement === 'inside' && indicator}
</ContentWrapper>
)}
{indicatorPlacement === 'end' && indicator}
</ChakraCheckboxCard.Control>
{addon && <ChakraCheckboxCard.Addon>{addon}</ChakraCheckboxCard.Addon>}
</ChakraCheckboxCard.Root>
);
});
export const CheckboxCardIndicator = ChakraCheckboxCard.Indicator;
+19
View File
@@ -0,0 +1,19 @@
import { Checkbox as ChakraCheckbox } from '@chakra-ui/react';
import * as React from 'react';
export interface CheckboxProps extends ChakraCheckbox.RootProps {
icon?: React.ReactNode;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
rootRef?: React.RefObject<HTMLLabelElement | null>;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(props, ref) {
const { icon, children, inputProps, rootRef, ...rest } = props;
return (
<ChakraCheckbox.Root ref={rootRef} {...rest}>
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckbox.Control>{icon || <ChakraCheckbox.Indicator />}</ChakraCheckbox.Control>
{children != null && <ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>}
</ChakraCheckbox.Root>
);
});
+174
View File
@@ -0,0 +1,174 @@
import type { IconButtonProps, StackProps } from '@chakra-ui/react';
import { ColorPicker as ChakraColorPicker, For, IconButton, Portal, Span, Stack, Text, VStack } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck, LuPipette } from 'react-icons/lu';
export const ColorPickerTrigger = React.forwardRef<
HTMLButtonElement,
ChakraColorPicker.TriggerProps & { fitContent?: boolean }
>(function ColorPickerTrigger(props, ref) {
const { fitContent, ...rest } = props;
return (
<ChakraColorPicker.Trigger data-fit-content={fitContent || undefined} ref={ref} {...rest}>
{props.children || <ChakraColorPicker.ValueSwatch />}
</ChakraColorPicker.Trigger>
);
});
export const ColorPickerInput = React.forwardRef<
HTMLInputElement,
Omit<ChakraColorPicker.ChannelInputProps, 'channel'>
>(function ColorHexInput(props, ref) {
return <ChakraColorPicker.ChannelInput channel='hex' ref={ref} {...props} />;
});
interface ColorPickerContentProps extends ChakraColorPicker.ContentProps {
portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>;
}
export const ColorPickerContent = React.forwardRef<HTMLDivElement, ColorPickerContentProps>(
function ColorPickerContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraColorPicker.Positioner>
<ChakraColorPicker.Content ref={ref} {...rest} />
</ChakraColorPicker.Positioner>
</Portal>
);
},
);
export const ColorPickerInlineContent = React.forwardRef<HTMLDivElement, ChakraColorPicker.ContentProps>(
function ColorPickerInlineContent(props, ref) {
return <ChakraColorPicker.Content animation='none' shadow='none' padding='0' ref={ref} {...props} />;
},
);
export const ColorPickerSliders = React.forwardRef<HTMLDivElement, StackProps>(function ColorPickerSliders(props, ref) {
return (
<Stack gap='1' flex='1' px='1' ref={ref} {...props}>
<ColorPickerChannelSlider channel='hue' />
<ColorPickerChannelSlider channel='alpha' />
</Stack>
);
});
export const ColorPickerArea = React.forwardRef<HTMLDivElement, ChakraColorPicker.AreaProps>(
function ColorPickerArea(props, ref) {
return (
<ChakraColorPicker.Area ref={ref} {...props}>
<ChakraColorPicker.AreaBackground />
<ChakraColorPicker.AreaThumb />
</ChakraColorPicker.Area>
);
},
);
export const ColorPickerEyeDropper = React.forwardRef<HTMLButtonElement, IconButtonProps>(
function ColorPickerEyeDropper(props, ref) {
return (
<ChakraColorPicker.EyeDropperTrigger asChild>
<IconButton size='xs' variant='outline' ref={ref} {...props}>
<LuPipette />
</IconButton>
</ChakraColorPicker.EyeDropperTrigger>
);
},
);
export const ColorPickerChannelSlider = React.forwardRef<HTMLDivElement, ChakraColorPicker.ChannelSliderProps>(
function ColorPickerSlider(props, ref) {
return (
<ChakraColorPicker.ChannelSlider ref={ref} {...props}>
<ChakraColorPicker.TransparencyGrid size='0.6rem' />
<ChakraColorPicker.ChannelSliderTrack />
<ChakraColorPicker.ChannelSliderThumb />
</ChakraColorPicker.ChannelSlider>
);
},
);
export const ColorPickerSwatchTrigger = React.forwardRef<
HTMLButtonElement,
ChakraColorPicker.SwatchTriggerProps & {
swatchSize?: ChakraColorPicker.SwatchTriggerProps['boxSize'];
}
>(function ColorPickerSwatchTrigger(props, ref) {
const { swatchSize, children, ...rest } = props;
return (
<ChakraColorPicker.SwatchTrigger ref={ref} style={{ ['--color' as string]: props.value }} {...rest}>
{children || (
<ChakraColorPicker.Swatch boxSize={swatchSize} value={props.value}>
<ChakraColorPicker.SwatchIndicator>
<LuCheck />
</ChakraColorPicker.SwatchIndicator>
</ChakraColorPicker.Swatch>
)}
</ChakraColorPicker.SwatchTrigger>
);
});
export const ColorPickerRoot = React.forwardRef<HTMLDivElement, ChakraColorPicker.RootProps>(
function ColorPickerRoot(props, ref) {
return (
<ChakraColorPicker.Root ref={ref} {...props}>
{props.children}
<ChakraColorPicker.HiddenInput tabIndex={-1} />
</ChakraColorPicker.Root>
);
},
);
const formatMap = {
rgba: ['red', 'green', 'blue', 'alpha'],
hsla: ['hue', 'saturation', 'lightness', 'alpha'],
hsba: ['hue', 'saturation', 'brightness', 'alpha'],
hexa: ['hex', 'alpha'],
} as const;
export const ColorPickerChannelInputs = React.forwardRef<HTMLDivElement, ChakraColorPicker.ViewProps>(
function ColorPickerChannelInputs(props, ref) {
const channels = formatMap[props.format];
return (
<ChakraColorPicker.View flexDirection='row' ref={ref} {...props}>
{channels.map((channel) => (
<VStack gap='1' key={channel} flex='1'>
<ColorPickerChannelInput channel={channel} px='0' height='7' textStyle='xs' textAlign='center' />
<Text textStyle='xs' color='fg.muted' fontWeight='medium'>
{channel.charAt(0).toUpperCase()}
</Text>
</VStack>
))}
</ChakraColorPicker.View>
);
},
);
export const ColorPickerChannelSliders = React.forwardRef<HTMLDivElement, ChakraColorPicker.ViewProps>(
function ColorPickerChannelSliders(props, ref) {
const channels = formatMap[props.format];
return (
<ChakraColorPicker.View {...props} ref={ref}>
<For each={channels}>
{(channel) => (
<Stack gap='1' key={channel}>
<Span textStyle='xs' minW='5ch' textTransform='capitalize' fontWeight='medium'>
{channel}
</Span>
<ColorPickerChannelSlider channel={channel} />
</Stack>
)}
</For>
</ChakraColorPicker.View>
);
},
);
export const ColorPickerLabel = ChakraColorPicker.Label;
export const ColorPickerControl = ChakraColorPicker.Control;
export const ColorPickerValueText = ChakraColorPicker.ValueText;
export const ColorPickerValueSwatch = ChakraColorPicker.ValueSwatch;
export const ColorPickerChannelInput = ChakraColorPicker.ChannelInput;
export const ColorPickerSwatchGroup = ChakraColorPicker.SwatchGroup;
+26
View File
@@ -0,0 +1,26 @@
import { Field as ChakraField } from '@chakra-ui/react';
import * as React from 'react';
export interface FieldProps extends Omit<ChakraField.RootProps, 'label'> {
label?: React.ReactNode;
helperText?: React.ReactNode;
errorText?: React.ReactNode;
optionalText?: React.ReactNode;
}
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(function Field(props, ref) {
const { label, children, helperText, errorText, optionalText, ...rest } = props;
return (
<ChakraField.Root ref={ref} {...rest}>
{label && (
<ChakraField.Label>
{label}
<ChakraField.RequiredIndicator fallback={optionalText} />
</ChakraField.Label>
)}
{children}
{helperText && <ChakraField.HelperText>{helperText}</ChakraField.HelperText>}
{errorText && <ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>}
</ChakraField.Root>
);
});
+150
View File
@@ -0,0 +1,150 @@
'use client';
import type { ButtonProps, RecipeProps } from '@chakra-ui/react';
import {
Button,
FileUpload as ChakraFileUpload,
Icon,
IconButton,
Span,
Text,
useFileUploadContext,
useRecipe,
} from '@chakra-ui/react';
import * as React from 'react';
import { LuFile, LuUpload, LuX } from 'react-icons/lu';
export interface FileUploadRootProps extends ChakraFileUpload.RootProps {
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const FileUploadRoot = React.forwardRef<HTMLInputElement, FileUploadRootProps>(
function FileUploadRoot(props, ref) {
const { children, inputProps, ...rest } = props;
return (
<ChakraFileUpload.Root {...rest}>
<ChakraFileUpload.HiddenInput ref={ref} {...inputProps} />
{children}
</ChakraFileUpload.Root>
);
},
);
export interface FileUploadDropzoneProps extends ChakraFileUpload.DropzoneProps {
label: React.ReactNode;
description?: React.ReactNode;
}
export const FileUploadDropzone = React.forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
function FileUploadDropzone(props, ref) {
const { children, label, description, ...rest } = props;
return (
<ChakraFileUpload.Dropzone ref={ref} {...rest}>
<Icon fontSize='xl' color='fg.muted'>
<LuUpload />
</Icon>
<ChakraFileUpload.DropzoneContent>
<div>{label}</div>
{description && <Text color='fg.muted'>{description}</Text>}
</ChakraFileUpload.DropzoneContent>
{children}
</ChakraFileUpload.Dropzone>
);
},
);
interface VisibilityProps {
showSize?: boolean;
clearable?: boolean;
}
interface FileUploadItemProps extends VisibilityProps {
file: File;
}
const FileUploadItem = React.forwardRef<HTMLLIElement, FileUploadItemProps>(function FileUploadItem(props, ref) {
const { file, showSize, clearable } = props;
return (
<ChakraFileUpload.Item file={file} ref={ref}>
<ChakraFileUpload.ItemPreview asChild>
<Icon fontSize='lg' color='fg.muted'>
<LuFile />
</Icon>
</ChakraFileUpload.ItemPreview>
{showSize ? (
<ChakraFileUpload.ItemContent>
<ChakraFileUpload.ItemName />
<ChakraFileUpload.ItemSizeText />
</ChakraFileUpload.ItemContent>
) : (
<ChakraFileUpload.ItemName flex='1' />
)}
{clearable && (
<ChakraFileUpload.ItemDeleteTrigger asChild>
<IconButton variant='ghost' color='fg.muted' size='xs'>
<LuX />
</IconButton>
</ChakraFileUpload.ItemDeleteTrigger>
)}
</ChakraFileUpload.Item>
);
});
interface FileUploadListProps extends VisibilityProps, ChakraFileUpload.ItemGroupProps {
files?: File[];
}
export const FileUploadList = React.forwardRef<HTMLUListElement, FileUploadListProps>(
function FileUploadList(props, ref) {
const { showSize, clearable, files, ...rest } = props;
const fileUpload = useFileUploadContext();
const acceptedFiles = files ?? fileUpload.acceptedFiles;
if (acceptedFiles.length === 0) return null;
return (
<ChakraFileUpload.ItemGroup ref={ref} {...rest}>
{acceptedFiles.map((file) => (
<FileUploadItem key={file.name} file={file} showSize={showSize} clearable={clearable} />
))}
</ChakraFileUpload.ItemGroup>
);
},
);
type Assign<T, U> = Omit<T, keyof U> & U;
interface FileInputProps extends Assign<ButtonProps, RecipeProps<'input'>> {
placeholder?: React.ReactNode;
}
export const FileInput = React.forwardRef<HTMLButtonElement, FileInputProps>(function FileInput(props, ref) {
const inputRecipe = useRecipe({ key: 'input' });
const [recipeProps, restProps] = inputRecipe.splitVariantProps(props);
const { placeholder = 'Select file(s)', ...rest } = restProps;
return (
<ChakraFileUpload.Trigger asChild>
<Button unstyled py='0' ref={ref} {...rest} css={[inputRecipe(recipeProps), props.css]}>
<ChakraFileUpload.Context>
{({ acceptedFiles }) => {
if (acceptedFiles.length === 1) {
return <span>{acceptedFiles[0].name}</span>;
}
if (acceptedFiles.length > 1) {
return <span>{acceptedFiles.length} files</span>;
}
return <Span color='fg.subtle'>{placeholder}</Span>;
}}
</ChakraFileUpload.Context>
</Button>
</ChakraFileUpload.Trigger>
);
});
export const FileUploadLabel = ChakraFileUpload.Label;
export const FileUploadClearTrigger = ChakraFileUpload.ClearTrigger;
export const FileUploadTrigger = ChakraFileUpload.Trigger;
export const FileUploadFileText = ChakraFileUpload.FileText;
+50
View File
@@ -0,0 +1,50 @@
import type { BoxProps, InputElementProps } from '@chakra-ui/react';
import { Group, InputElement } from '@chakra-ui/react';
import * as React from 'react';
export interface InputGroupProps extends BoxProps {
startElementProps?: InputElementProps;
endElementProps?: InputElementProps;
startElement?: React.ReactNode;
endElement?: React.ReactNode;
children: React.ReactElement<InputElementProps>;
startOffset?: InputElementProps['paddingStart'];
endOffset?: InputElementProps['paddingEnd'];
}
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(function InputGroup(props, ref) {
const {
startElement,
startElementProps,
endElement,
endElementProps,
children,
startOffset = '6px',
endOffset = '6px',
...rest
} = props;
const child = React.Children.only<React.ReactElement<InputElementProps>>(children);
return (
<Group ref={ref} {...rest}>
{startElement && (
<InputElement pointerEvents='none' {...startElementProps}>
{startElement}
</InputElement>
)}
{React.cloneElement(child, {
...(startElement && {
ps: `calc(var(--input-height) - ${startOffset})`,
}),
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
...children.props,
})}
{endElement && (
<InputElement placement='end' {...endElementProps}>
{endElement}
</InputElement>
)}
</Group>
);
});
+52
View File
@@ -0,0 +1,52 @@
'use client';
import { NativeSelect as Select } from '@chakra-ui/react';
import * as React from 'react';
interface NativeSelectRootProps extends Select.RootProps {
icon?: React.ReactNode;
}
export const NativeSelectRoot = React.forwardRef<HTMLDivElement, NativeSelectRootProps>(
function NativeSelect(props, ref) {
const { icon, children, ...rest } = props;
return (
<Select.Root ref={ref} {...rest}>
{children}
<Select.Indicator>{icon}</Select.Indicator>
</Select.Root>
);
},
);
interface NativeSelectItem {
value: string;
label: string;
disabled?: boolean;
}
interface NativeSelectFieldProps extends Select.FieldProps {
items?: Array<string | NativeSelectItem>;
}
export const NativeSelectField = React.forwardRef<HTMLSelectElement, NativeSelectFieldProps>(
function NativeSelectField(props, ref) {
const { items: itemsProp, children, ...rest } = props;
const items = React.useMemo(
() => itemsProp?.map((item) => (typeof item === 'string' ? { label: item, value: item } : item)),
[itemsProp],
);
return (
<Select.Field ref={ref} {...rest}>
{children}
{items?.map((item) => (
<option key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</option>
))}
</Select.Field>
);
},
);

Some files were not shown because too many files have changed in this diff Show More