This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DashboardContent } from "./dashboard-content";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as HomeContent } from "./home-content";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (0–1). 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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ProfileContent } from "./profile-content";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NumberInput as ChakraNumberInput } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface NumberInputProps extends ChakraNumberInput.RootProps {}
|
||||
|
||||
export const NumberInputRoot = React.forwardRef<HTMLDivElement, NumberInputProps>(function NumberInput(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraNumberInput.Root ref={ref} variant='outline' {...rest}>
|
||||
{children}
|
||||
<ChakraNumberInput.Control>
|
||||
<ChakraNumberInput.IncrementTrigger />
|
||||
<ChakraNumberInput.DecrementTrigger />
|
||||
</ChakraNumberInput.Control>
|
||||
</ChakraNumberInput.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const NumberInputField = ChakraNumberInput.Input;
|
||||
export const NumberInputScrubber = ChakraNumberInput.Scrubber;
|
||||
export const NumberInputLabel = ChakraNumberInput.Label;
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import type { ButtonProps, GroupProps, InputProps, StackProps } from '@chakra-ui/react';
|
||||
import { Box, HStack, IconButton, Input, InputGroup, Stack, mergeRefs, useControllableState } from '@chakra-ui/react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from 'react';
|
||||
import { LuEye, LuEyeOff } from 'react-icons/lu';
|
||||
|
||||
export interface PasswordVisibilityProps {
|
||||
/**
|
||||
* The default visibility state of the password input.
|
||||
*/
|
||||
defaultVisible?: boolean;
|
||||
/**
|
||||
* The controlled visibility state of the password input.
|
||||
*/
|
||||
visible?: boolean;
|
||||
/**
|
||||
* Callback invoked when the visibility state changes.
|
||||
*/
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
/**
|
||||
* Custom icons for the visibility toggle button.
|
||||
*/
|
||||
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode };
|
||||
}
|
||||
|
||||
export interface PasswordInputProps extends InputProps, PasswordVisibilityProps {
|
||||
rootProps?: GroupProps;
|
||||
}
|
||||
|
||||
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(props, ref) {
|
||||
const {
|
||||
rootProps,
|
||||
defaultVisible,
|
||||
visible: visibleProp,
|
||||
onVisibleChange,
|
||||
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const [visible, setVisible] = useControllableState({
|
||||
value: visibleProp,
|
||||
defaultValue: defaultVisible || false,
|
||||
onChange: onVisibleChange,
|
||||
});
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<InputGroup
|
||||
endElement={
|
||||
<VisibilityTrigger
|
||||
disabled={rest.disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (rest.disabled) return;
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setVisible(!visible);
|
||||
}}
|
||||
>
|
||||
{visible ? visibilityIcon.off : visibilityIcon.on}
|
||||
</VisibilityTrigger>
|
||||
}
|
||||
{...rootProps}
|
||||
>
|
||||
<Input {...rest} ref={mergeRefs(ref, inputRef)} type={visible ? 'text' : 'password'} />
|
||||
</InputGroup>
|
||||
);
|
||||
});
|
||||
|
||||
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(function VisibilityTrigger(props, ref) {
|
||||
return (
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
me='-2'
|
||||
aspectRatio='square'
|
||||
borderRadius='full'
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
height='calc(100% - {spacing.2})'
|
||||
aria-label='Toggle password visibility'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface PasswordStrengthMeterProps extends StackProps {
|
||||
max?: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter = React.forwardRef<HTMLDivElement, PasswordStrengthMeterProps>(
|
||||
function PasswordStrengthMeter(props, ref) {
|
||||
const { max = 4, value, ...rest } = props;
|
||||
const t = useTranslations();
|
||||
|
||||
function getColorPalette(percent: number) {
|
||||
switch (true) {
|
||||
case percent < 33:
|
||||
return { label: t('low'), colorPalette: 'red' };
|
||||
case percent < 66:
|
||||
return { label: t('medium'), colorPalette: 'orange' };
|
||||
default:
|
||||
return { label: t('high'), colorPalette: 'green' };
|
||||
}
|
||||
}
|
||||
|
||||
const percent = (value / max) * 100;
|
||||
const { label, colorPalette } = getColorPalette(percent);
|
||||
|
||||
return (
|
||||
<Stack align='flex-end' gap='1' ref={ref} {...rest}>
|
||||
<HStack width='full' {...rest}>
|
||||
{Array.from({ length: max }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
height='1'
|
||||
flex='1'
|
||||
rounded='sm'
|
||||
data-selected={index < value ? '' : undefined}
|
||||
layerStyle='fill.subtle'
|
||||
colorPalette='gray'
|
||||
_selected={{
|
||||
colorPalette,
|
||||
layerStyle: 'fill.solid',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{label && <HStack textStyle='xs'>{label}</HStack>}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PinInput as ChakraPinInput, Group } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface PinInputProps extends ChakraPinInput.RootProps {
|
||||
rootRef?: React.RefObject<HTMLDivElement | null>;
|
||||
count?: number;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
attached?: boolean;
|
||||
}
|
||||
|
||||
export const PinInput = React.forwardRef<HTMLInputElement, PinInputProps>(function PinInput(props, ref) {
|
||||
const { count = 4, inputProps, rootRef, attached, ...rest } = props;
|
||||
return (
|
||||
<ChakraPinInput.Root ref={rootRef} {...rest}>
|
||||
<ChakraPinInput.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraPinInput.Control>
|
||||
<Group attached={attached}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<ChakraPinInput.Input key={index} index={index} />
|
||||
))}
|
||||
</Group>
|
||||
</ChakraPinInput.Control>
|
||||
</ChakraPinInput.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { RadioCard } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface RadioCardItemProps extends RadioCard.ItemProps {
|
||||
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 RadioCardItem = React.forwardRef<HTMLInputElement, RadioCardItemProps>(function RadioCardItem(props, ref) {
|
||||
const {
|
||||
inputProps,
|
||||
label,
|
||||
description,
|
||||
addon,
|
||||
icon,
|
||||
indicator = <RadioCard.ItemIndicator />,
|
||||
indicatorPlacement = 'end',
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const hasContent = label || description || icon;
|
||||
const ContentWrapper = indicator ? RadioCard.ItemContent : React.Fragment;
|
||||
|
||||
return (
|
||||
<RadioCard.Item {...rest}>
|
||||
<RadioCard.ItemHiddenInput ref={ref} {...inputProps} />
|
||||
<RadioCard.ItemControl>
|
||||
{indicatorPlacement === 'start' && indicator}
|
||||
{hasContent && (
|
||||
<ContentWrapper>
|
||||
{icon}
|
||||
{label && <RadioCard.ItemText>{label}</RadioCard.ItemText>}
|
||||
{description && <RadioCard.ItemDescription>{description}</RadioCard.ItemDescription>}
|
||||
{indicatorPlacement === 'inside' && indicator}
|
||||
</ContentWrapper>
|
||||
)}
|
||||
{indicatorPlacement === 'end' && indicator}
|
||||
</RadioCard.ItemControl>
|
||||
{addon && <RadioCard.ItemAddon>{addon}</RadioCard.ItemAddon>}
|
||||
</RadioCard.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const RadioCardRoot = RadioCard.Root;
|
||||
export const RadioCardLabel = RadioCard.Label;
|
||||
export const RadioCardItemIndicator = RadioCard.ItemIndicator;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RadioGroup as ChakraRadioGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface RadioProps extends ChakraRadioGroup.ItemProps {
|
||||
rootRef?: React.RefObject<HTMLDivElement | null>;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(function Radio(props, ref) {
|
||||
const { children, inputProps, rootRef, ...rest } = props;
|
||||
return (
|
||||
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
|
||||
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraRadioGroup.ItemIndicator />
|
||||
{children && <ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>}
|
||||
</ChakraRadioGroup.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export const RadioGroup = ChakraRadioGroup.Root;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RatingGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface RatingProps extends RatingGroup.RootProps {
|
||||
icon?: React.ReactElement;
|
||||
count?: number;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Rating = React.forwardRef<HTMLDivElement, RatingProps>(function Rating(props, ref) {
|
||||
const { icon, count = 5, label, ...rest } = props;
|
||||
return (
|
||||
<RatingGroup.Root ref={ref} count={count} {...rest}>
|
||||
{label && <RatingGroup.Label>{label}</RatingGroup.Label>}
|
||||
<RatingGroup.HiddenInput />
|
||||
<RatingGroup.Control>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RatingGroup.Item key={index} index={index + 1}>
|
||||
<RatingGroup.ItemIndicator icon={icon} />
|
||||
</RatingGroup.Item>
|
||||
))}
|
||||
</RatingGroup.Control>
|
||||
</RatingGroup.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { For, SegmentGroup } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface Item {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SegmentedControlProps extends SegmentGroup.RootProps {
|
||||
items: Array<string | Item>;
|
||||
}
|
||||
|
||||
function normalize(items: Array<string | Item>): Item[] {
|
||||
return items.map((item) => {
|
||||
if (typeof item === 'string') return { value: item, label: item };
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export const SegmentedControl = React.forwardRef<HTMLDivElement, SegmentedControlProps>(
|
||||
function SegmentedControl(props, ref) {
|
||||
const { items, ...rest } = props;
|
||||
const data = React.useMemo(() => normalize(items), [items]);
|
||||
|
||||
return (
|
||||
<SegmentGroup.Root ref={ref} {...rest}>
|
||||
<SegmentGroup.Indicator />
|
||||
<For each={data}>
|
||||
{(item) => (
|
||||
<SegmentGroup.Item key={item.value} value={item.value} disabled={item.disabled}>
|
||||
<SegmentGroup.ItemText>{item.label}</SegmentGroup.ItemText>
|
||||
<SegmentGroup.ItemHiddenInput />
|
||||
</SegmentGroup.Item>
|
||||
)}
|
||||
</For>
|
||||
</SegmentGroup.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Slider as ChakraSlider, For, HStack } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface SliderProps extends ChakraSlider.RootProps {
|
||||
marks?: Array<number | { value: number; label: React.ReactNode }>;
|
||||
label?: React.ReactNode;
|
||||
showValue?: boolean;
|
||||
thumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(function Slider(props, ref) {
|
||||
const { marks: marksProp, label, showValue, thumb, ...rest } = props;
|
||||
const value = props.defaultValue ?? props.value;
|
||||
|
||||
const marks = marksProp?.map((mark) => {
|
||||
if (typeof mark === 'number') return { value: mark, label: undefined };
|
||||
return mark;
|
||||
});
|
||||
|
||||
const hasMarkLabel = !!marks?.some((mark) => mark.label);
|
||||
|
||||
return (
|
||||
<ChakraSlider.Root ref={ref} thumbAlignment='center' {...rest}>
|
||||
{label && !showValue && <ChakraSlider.Label>{label}</ChakraSlider.Label>}
|
||||
{label && showValue && (
|
||||
<HStack justify='space-between'>
|
||||
<ChakraSlider.Label>{label}</ChakraSlider.Label>
|
||||
<ChakraSlider.ValueText />
|
||||
</HStack>
|
||||
)}
|
||||
<ChakraSlider.Control data-has-mark-label={hasMarkLabel || undefined}>
|
||||
<ChakraSlider.Track>
|
||||
<ChakraSlider.Range />
|
||||
</ChakraSlider.Track>
|
||||
<SliderThumbs value={value} thumb={thumb} />
|
||||
<SliderMarks marks={marks} />
|
||||
</ChakraSlider.Control>
|
||||
</ChakraSlider.Root>
|
||||
);
|
||||
});
|
||||
|
||||
function SliderThumbs(props: { value?: number[]; thumb?: React.ReactNode }) {
|
||||
const { value, thumb } = props;
|
||||
return (
|
||||
<For each={value}>
|
||||
{(_, index) => (
|
||||
<ChakraSlider.Thumb key={index} index={index}>
|
||||
<ChakraSlider.HiddenInput />
|
||||
{thumb}
|
||||
</ChakraSlider.Thumb>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
interface SliderMarksProps {
|
||||
marks?: Array<number | { value: number; label: React.ReactNode }>;
|
||||
}
|
||||
|
||||
const SliderMarks = React.forwardRef<HTMLDivElement, SliderMarksProps>(function SliderMarks(props, ref) {
|
||||
const { marks } = props;
|
||||
if (!marks?.length) return null;
|
||||
|
||||
return (
|
||||
<ChakraSlider.MarkerGroup ref={ref}>
|
||||
{marks.map((mark, index) => {
|
||||
const value = typeof mark === 'number' ? mark : mark.value;
|
||||
const label = typeof mark === 'number' ? undefined : mark.label;
|
||||
return (
|
||||
<ChakraSlider.Marker key={index} value={value}>
|
||||
<ChakraSlider.MarkerIndicator />
|
||||
{label}
|
||||
</ChakraSlider.Marker>
|
||||
);
|
||||
})}
|
||||
</ChakraSlider.MarkerGroup>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { HStack, IconButton, NumberInput } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuMinus, LuPlus } from 'react-icons/lu';
|
||||
|
||||
export interface StepperInputProps extends NumberInput.RootProps {
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StepperInput = React.forwardRef<HTMLDivElement, StepperInputProps>(function StepperInput(props, ref) {
|
||||
const { label, ...rest } = props;
|
||||
return (
|
||||
<NumberInput.Root {...rest} unstyled ref={ref}>
|
||||
{label && <NumberInput.Label>{label}</NumberInput.Label>}
|
||||
<HStack gap='2'>
|
||||
<DecrementTrigger />
|
||||
<NumberInput.ValueText textAlign='center' fontSize='lg' minW='3ch' />
|
||||
<IncrementTrigger />
|
||||
</HStack>
|
||||
</NumberInput.Root>
|
||||
);
|
||||
});
|
||||
|
||||
const DecrementTrigger = React.forwardRef<HTMLButtonElement, NumberInput.DecrementTriggerProps>(
|
||||
function DecrementTrigger(props, ref) {
|
||||
return (
|
||||
<NumberInput.DecrementTrigger {...props} asChild ref={ref}>
|
||||
<IconButton variant='outline' size='sm'>
|
||||
<LuMinus />
|
||||
</IconButton>
|
||||
</NumberInput.DecrementTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const IncrementTrigger = React.forwardRef<HTMLButtonElement, NumberInput.IncrementTriggerProps>(
|
||||
function IncrementTrigger(props, ref) {
|
||||
return (
|
||||
<NumberInput.IncrementTrigger {...props} asChild ref={ref}>
|
||||
<IconButton variant='outline' size='sm'>
|
||||
<LuPlus />
|
||||
</IconButton>
|
||||
</NumberInput.IncrementTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Switch as ChakraSwitch } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface SwitchProps extends ChakraSwitch.RootProps {
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
rootRef?: React.RefObject<HTMLLabelElement | null>;
|
||||
trackLabel?: { on: React.ReactNode; off: React.ReactNode };
|
||||
thumbLabel?: { on: React.ReactNode; off: React.ReactNode };
|
||||
}
|
||||
|
||||
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(function Switch(props, ref) {
|
||||
const { inputProps, children, rootRef, trackLabel, thumbLabel, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraSwitch.Root ref={rootRef} {...rest}>
|
||||
<ChakraSwitch.HiddenInput ref={ref} {...inputProps} />
|
||||
<ChakraSwitch.Control>
|
||||
<ChakraSwitch.Thumb>
|
||||
{thumbLabel && (
|
||||
<ChakraSwitch.ThumbIndicator fallback={thumbLabel?.off}>{thumbLabel?.on}</ChakraSwitch.ThumbIndicator>
|
||||
)}
|
||||
</ChakraSwitch.Thumb>
|
||||
{trackLabel && <ChakraSwitch.Indicator fallback={trackLabel.off}>{trackLabel.on}</ChakraSwitch.Indicator>}
|
||||
</ChakraSwitch.Control>
|
||||
{children != null && <ChakraSwitch.Label>{children}</ChakraSwitch.Label>}
|
||||
</ChakraSwitch.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React, { useTransition } from 'react';
|
||||
import { Locale, useLocale } from 'next-intl';
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValueText,
|
||||
} from '@/components/ui/collections/select';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { createListCollection } from '@chakra-ui/react';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
|
||||
const LocaleSwitcher = () => {
|
||||
const locale = useLocale();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const collections = createListCollection({
|
||||
items: [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Türkçe', value: 'tr' },
|
||||
],
|
||||
});
|
||||
|
||||
function onSelectChange({ value }: { value: string[] }) {
|
||||
const nextLocale = value.at(0) as Locale;
|
||||
startTransition(() => {
|
||||
router.replace(
|
||||
// @ts-expect-error -- TypeScript will validate that only known `params`
|
||||
// are used in combination with a given `pathname`. Since the two will
|
||||
// always match for the current route, we can skip runtime checks.
|
||||
{ pathname, params },
|
||||
{ locale: nextLocale },
|
||||
);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<SelectRoot
|
||||
disabled={isPending}
|
||||
value={[locale]}
|
||||
onValueChange={onSelectChange}
|
||||
w={{ base: 'full', lg: '24' }}
|
||||
size='sm'
|
||||
variant='outline'
|
||||
borderRadius='md'
|
||||
collection={collections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValueText placeholder='Select a language' />
|
||||
</SelectTrigger>
|
||||
<SelectContent zIndex='9999'>
|
||||
{collections.items.map((collection) => (
|
||||
<SelectItem key={collection.value} item={collection}>
|
||||
{collection.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocaleSwitcher;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ActionBar, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ActionBarContentProps extends ActionBar.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ActionBarContent = React.forwardRef<HTMLDivElement, ActionBarContentProps>(
|
||||
function ActionBarContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ActionBar.Positioner>
|
||||
<ActionBar.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ActionBar.Content>
|
||||
</ActionBar.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ActionBarCloseTrigger = React.forwardRef<HTMLButtonElement, ActionBar.CloseTriggerProps>(
|
||||
function ActionBarCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ActionBar.CloseTrigger {...props} asChild ref={ref}>
|
||||
<CloseButton size='sm' />
|
||||
</ActionBar.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const ActionBarRoot = ActionBar.Root;
|
||||
export const ActionBarSelectionTrigger = ActionBar.SelectionTrigger;
|
||||
export const ActionBarSeparator = ActionBar.Separator;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Dialog as ChakraDialog, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface DialogContentProps extends ChakraDialog.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
backdrop?: boolean;
|
||||
}
|
||||
|
||||
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(function DialogContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, backdrop = true, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
{backdrop && <ChakraDialog.Backdrop />}
|
||||
<ChakraDialog.Positioner>
|
||||
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDialog.Content>
|
||||
</ChakraDialog.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const DialogCloseTrigger = React.forwardRef<HTMLButtonElement, ChakraDialog.CloseTriggerProps>(
|
||||
function DialogCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDialog.CloseTrigger position='absolute' top='2' insetEnd='2' {...props} asChild>
|
||||
<CloseButton size='sm' ref={ref}>
|
||||
{props.children}
|
||||
</CloseButton>
|
||||
</ChakraDialog.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const DialogRoot = ChakraDialog.Root;
|
||||
export const DialogFooter = ChakraDialog.Footer;
|
||||
export const DialogHeader = ChakraDialog.Header;
|
||||
export const DialogBody = ChakraDialog.Body;
|
||||
export const DialogBackdrop = ChakraDialog.Backdrop;
|
||||
export const DialogTitle = ChakraDialog.Title;
|
||||
export const DialogDescription = ChakraDialog.Description;
|
||||
export const DialogTrigger = ChakraDialog.Trigger;
|
||||
export const DialogActionTrigger = ChakraDialog.ActionTrigger;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Drawer as ChakraDrawer, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '@/components/ui/buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface DrawerContentProps extends ChakraDrawer.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
offset?: ChakraDrawer.ContentProps['padding'];
|
||||
}
|
||||
|
||||
export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(function DrawerContent(props, ref) {
|
||||
const { children, portalled = true, portalRef, offset, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraDrawer.Positioner padding={offset}>
|
||||
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
|
||||
{children}
|
||||
</ChakraDrawer.Content>
|
||||
</ChakraDrawer.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const DrawerCloseTrigger = React.forwardRef<HTMLButtonElement, ChakraDrawer.CloseTriggerProps>(
|
||||
function DrawerCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraDrawer.CloseTrigger position='absolute' top='2' insetEnd='2' {...props} asChild>
|
||||
<CloseButton size='sm' ref={ref} />
|
||||
</ChakraDrawer.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const DrawerTrigger = ChakraDrawer.Trigger;
|
||||
export const DrawerRoot = ChakraDrawer.Root;
|
||||
export const DrawerFooter = ChakraDrawer.Footer;
|
||||
export const DrawerHeader = ChakraDrawer.Header;
|
||||
export const DrawerBody = ChakraDrawer.Body;
|
||||
export const DrawerBackdrop = ChakraDrawer.Backdrop;
|
||||
export const DrawerDescription = ChakraDrawer.Description;
|
||||
export const DrawerTitle = ChakraDrawer.Title;
|
||||
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HoverCard, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
interface HoverCardContentProps extends HoverCard.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(
|
||||
function HoverCardContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<HoverCard.Positioner>
|
||||
<HoverCard.Content ref={ref} {...rest} />
|
||||
</HoverCard.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const HoverCardArrow = React.forwardRef<HTMLDivElement, HoverCard.ArrowProps>(
|
||||
function HoverCardArrow(props, ref) {
|
||||
return (
|
||||
<HoverCard.Arrow ref={ref} {...props}>
|
||||
<HoverCard.ArrowTip />
|
||||
</HoverCard.Arrow>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const HoverCardRoot = HoverCard.Root;
|
||||
export const HoverCardTrigger = HoverCard.Trigger;
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { LuCheck, LuChevronRight } from 'react-icons/lu';
|
||||
|
||||
interface MenuContentProps extends ChakraMenu.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(function MenuContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraMenu.Positioner>
|
||||
<ChakraMenu.Content ref={ref} {...rest} />
|
||||
</ChakraMenu.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const MenuArrow = React.forwardRef<HTMLDivElement, ChakraMenu.ArrowProps>(function MenuArrow(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.Arrow ref={ref} {...props}>
|
||||
<ChakraMenu.ArrowTip />
|
||||
</ChakraMenu.Arrow>
|
||||
);
|
||||
});
|
||||
|
||||
export const MenuCheckboxItem = React.forwardRef<HTMLDivElement, ChakraMenu.CheckboxItemProps>(
|
||||
function MenuCheckboxItem(props, ref) {
|
||||
return (
|
||||
<ChakraMenu.CheckboxItem ps='8' ref={ref} {...props}>
|
||||
<AbsoluteCenter axis='horizontal' insetStart='4' asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
{props.children}
|
||||
</ChakraMenu.CheckboxItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MenuRadioItem = React.forwardRef<HTMLDivElement, ChakraMenu.RadioItemProps>(
|
||||
function MenuRadioItem(props, ref) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ChakraMenu.RadioItem ps='8' ref={ref} {...rest}>
|
||||
<AbsoluteCenter axis='horizontal' insetStart='4' asChild>
|
||||
<ChakraMenu.ItemIndicator>
|
||||
<LuCheck />
|
||||
</ChakraMenu.ItemIndicator>
|
||||
</AbsoluteCenter>
|
||||
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
|
||||
</ChakraMenu.RadioItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MenuItemGroup = React.forwardRef<HTMLDivElement, ChakraMenu.ItemGroupProps>(
|
||||
function MenuItemGroup(props, ref) {
|
||||
const { title, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraMenu.ItemGroup ref={ref} {...rest}>
|
||||
{title && <ChakraMenu.ItemGroupLabel userSelect='none'>{title}</ChakraMenu.ItemGroupLabel>}
|
||||
{children}
|
||||
</ChakraMenu.ItemGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
|
||||
startIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MenuTriggerItem = React.forwardRef<HTMLDivElement, MenuTriggerItemProps>(
|
||||
function MenuTriggerItem(props, ref) {
|
||||
const { startIcon, children, ...rest } = props;
|
||||
return (
|
||||
<ChakraMenu.TriggerItem ref={ref} {...rest}>
|
||||
{startIcon}
|
||||
{children}
|
||||
<LuChevronRight />
|
||||
</ChakraMenu.TriggerItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup;
|
||||
export const MenuContextTrigger = ChakraMenu.ContextTrigger;
|
||||
export const MenuRoot = ChakraMenu.Root;
|
||||
export const MenuSeparator = ChakraMenu.Separator;
|
||||
|
||||
export const MenuItem = ChakraMenu.Item;
|
||||
export const MenuItemText = ChakraMenu.ItemText;
|
||||
export const MenuItemCommand = ChakraMenu.ItemCommand;
|
||||
export const MenuTrigger = ChakraMenu.Trigger;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Popover as ChakraPopover, Portal } from '@chakra-ui/react';
|
||||
import { CloseButton } from '../buttons/close-button';
|
||||
import * as React from 'react';
|
||||
|
||||
interface PopoverContentProps extends ChakraPopover.ContentProps {
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||
function PopoverContent(props, ref) {
|
||||
const { portalled = true, portalRef, ...rest } = props;
|
||||
return (
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraPopover.Positioner>
|
||||
<ChakraPopover.Content ref={ref} {...rest} />
|
||||
</ChakraPopover.Positioner>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PopoverArrow = React.forwardRef<HTMLDivElement, ChakraPopover.ArrowProps>(
|
||||
function PopoverArrow(props, ref) {
|
||||
return (
|
||||
<ChakraPopover.Arrow {...props} ref={ref}>
|
||||
<ChakraPopover.ArrowTip />
|
||||
</ChakraPopover.Arrow>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PopoverCloseTrigger = React.forwardRef<HTMLButtonElement, ChakraPopover.CloseTriggerProps>(
|
||||
function PopoverCloseTrigger(props, ref) {
|
||||
return (
|
||||
<ChakraPopover.CloseTrigger position='absolute' top='1' insetEnd='1' {...props} asChild ref={ref}>
|
||||
<CloseButton size='sm' />
|
||||
</ChakraPopover.CloseTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PopoverTitle = ChakraPopover.Title;
|
||||
export const PopoverDescription = ChakraPopover.Description;
|
||||
export const PopoverFooter = ChakraPopover.Footer;
|
||||
export const PopoverHeader = ChakraPopover.Header;
|
||||
export const PopoverRoot = ChakraPopover.Root;
|
||||
export const PopoverBody = ChakraPopover.Body;
|
||||
export const PopoverTrigger = ChakraPopover.Trigger;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Popover as ChakraPopover, IconButton, type IconButtonProps, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
import { HiOutlineInformationCircle } from 'react-icons/hi';
|
||||
|
||||
export interface ToggleTipProps extends ChakraPopover.RootProps {
|
||||
showArrow?: boolean;
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
content?: React.ReactNode;
|
||||
contentProps?: ChakraPopover.ContentProps;
|
||||
}
|
||||
|
||||
export const ToggleTip = React.forwardRef<HTMLDivElement, ToggleTipProps>(function ToggleTip(props, ref) {
|
||||
const { showArrow, children, portalled = true, content, contentProps, portalRef, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraPopover.Root {...rest} positioning={{ ...rest.positioning, gutter: 4 }}>
|
||||
<ChakraPopover.Trigger asChild>{children}</ChakraPopover.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraPopover.Positioner>
|
||||
<ChakraPopover.Content width='auto' px='2' py='1' textStyle='xs' rounded='sm' ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraPopover.Arrow>
|
||||
<ChakraPopover.ArrowTip />
|
||||
</ChakraPopover.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraPopover.Content>
|
||||
</ChakraPopover.Positioner>
|
||||
</Portal>
|
||||
</ChakraPopover.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export interface InfoTipProps extends Partial<ToggleTipProps> {
|
||||
buttonProps?: IconButtonProps | undefined;
|
||||
}
|
||||
|
||||
export const InfoTip = React.forwardRef<HTMLDivElement, InfoTipProps>(function InfoTip(props, ref) {
|
||||
const { children, buttonProps, ...rest } = props;
|
||||
return (
|
||||
<ToggleTip content={children} {...rest} ref={ref}>
|
||||
<IconButton variant='ghost' aria-label='info' size='2xs' colorPalette='gray' {...buttonProps}>
|
||||
<HiOutlineInformationCircle />
|
||||
</IconButton>
|
||||
</ToggleTip>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface TooltipProps extends ChakraTooltip.RootProps {
|
||||
showArrow?: boolean;
|
||||
portalled?: boolean;
|
||||
portalRef?: React.RefObject<HTMLElement | null>;
|
||||
content: React.ReactNode;
|
||||
contentProps?: ChakraTooltip.ContentProps;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(function Tooltip(props, ref) {
|
||||
const { showArrow, children, disabled, portalled = true, content, contentProps, portalRef, ...rest } = props;
|
||||
|
||||
if (disabled) return children;
|
||||
|
||||
return (
|
||||
<ChakraTooltip.Root {...rest}>
|
||||
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraTooltip.Positioner>
|
||||
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraTooltip.Arrow>
|
||||
<ChakraTooltip.ArrowTip />
|
||||
</ChakraTooltip.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraTooltip.Content>
|
||||
</ChakraTooltip.Positioner>
|
||||
</Portal>
|
||||
</ChakraTooltip.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode";
|
||||
import { system } from "../../theme/theme";
|
||||
import { Toaster } from "./feedback/toaster";
|
||||
import TopLoader from "./top-loader";
|
||||
import ReactQueryProvider from "@/providers/react-query-provider";
|
||||
import AOSProvider from "@/providers/aos-provider";
|
||||
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ChakraProvider value={system}>
|
||||
<ReactQueryProvider>
|
||||
<AOSProvider>
|
||||
<TopLoader />
|
||||
<ColorModeProvider {...props} />
|
||||
<Toaster />
|
||||
</AOSProvider>
|
||||
</ReactQueryProvider>
|
||||
</ChakraProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { useToken } from '@chakra-ui/react';
|
||||
|
||||
export default function TopLoader() {
|
||||
const [color] = useToken('colors', ['primary.500']);
|
||||
|
||||
return <NextTopLoader color={color} showSpinner={false} />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Blockquote as ChakraBlockquote } from '@chakra-ui/react';
|
||||
import * as React from 'react';
|
||||
|
||||
export interface BlockquoteProps extends ChakraBlockquote.RootProps {
|
||||
cite?: React.ReactNode;
|
||||
citeUrl?: string;
|
||||
icon?: React.ReactNode;
|
||||
showDash?: boolean;
|
||||
}
|
||||
|
||||
export const Blockquote = React.forwardRef<HTMLDivElement, BlockquoteProps>(function Blockquote(props, ref) {
|
||||
const { children, cite, citeUrl, showDash, icon, ...rest } = props;
|
||||
|
||||
return (
|
||||
<ChakraBlockquote.Root ref={ref} {...rest}>
|
||||
{icon}
|
||||
<ChakraBlockquote.Content cite={citeUrl}>{children}</ChakraBlockquote.Content>
|
||||
{cite && (
|
||||
<ChakraBlockquote.Caption>
|
||||
{showDash ? <>—</> : null} <cite>{cite}</cite>
|
||||
</ChakraBlockquote.Caption>
|
||||
)}
|
||||
</ChakraBlockquote.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const BlockquoteIcon = ChakraBlockquote.Icon;
|
||||
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { chakra } from '@chakra-ui/react';
|
||||
|
||||
const TRAILING_PSEUDO_REGEX = /(::?[\w-]+(?:\([^)]*\))?)+$/;
|
||||
const EXCLUDE_CLASSNAME = '.not-prose';
|
||||
function inWhere<T extends string>(selector: T): T {
|
||||
const rebuiltSelector = selector.startsWith('& ') ? selector.slice(2) : selector;
|
||||
const match = selector.match(TRAILING_PSEUDO_REGEX);
|
||||
const pseudo = match ? match[0] : '';
|
||||
const base = match ? selector.slice(0, -match[0].length) : rebuiltSelector;
|
||||
return `& :where(${base}):not(${EXCLUDE_CLASSNAME}, ${EXCLUDE_CLASSNAME} *)${pseudo}` as T;
|
||||
}
|
||||
|
||||
export const Prose = chakra('div', {
|
||||
base: {
|
||||
color: 'fg.muted',
|
||||
maxWidth: '65ch',
|
||||
fontSize: 'sm',
|
||||
lineHeight: '1.7em',
|
||||
[inWhere('& p')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& blockquote')]: {
|
||||
marginTop: '1.285em',
|
||||
marginBottom: '1.285em',
|
||||
paddingInline: '1.285em',
|
||||
borderInlineStartWidth: '0.25em',
|
||||
color: 'fg',
|
||||
},
|
||||
[inWhere('& a')]: {
|
||||
color: 'fg',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '3px',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationColor: 'border.muted',
|
||||
fontWeight: '500',
|
||||
},
|
||||
[inWhere('& strong')]: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
[inWhere('& a strong')]: {
|
||||
color: 'inherit',
|
||||
},
|
||||
[inWhere('& h1')]: {
|
||||
fontSize: '2.15em',
|
||||
letterSpacing: '-0.02em',
|
||||
marginTop: '0',
|
||||
marginBottom: '0.8em',
|
||||
lineHeight: '1.2em',
|
||||
},
|
||||
[inWhere('& h2')]: {
|
||||
fontSize: '1.4em',
|
||||
letterSpacing: '-0.02em',
|
||||
marginTop: '1.6em',
|
||||
marginBottom: '0.8em',
|
||||
lineHeight: '1.4em',
|
||||
},
|
||||
[inWhere('& h3')]: {
|
||||
fontSize: '1.285em',
|
||||
letterSpacing: '-0.01em',
|
||||
marginTop: '1.5em',
|
||||
marginBottom: '0.4em',
|
||||
lineHeight: '1.5em',
|
||||
},
|
||||
[inWhere('& h4')]: {
|
||||
marginTop: '1.4em',
|
||||
marginBottom: '0.5em',
|
||||
letterSpacing: '-0.01em',
|
||||
lineHeight: '1.5em',
|
||||
},
|
||||
[inWhere('& img')]: {
|
||||
marginTop: '1.7em',
|
||||
marginBottom: '1.7em',
|
||||
borderRadius: 'lg',
|
||||
boxShadow: 'inset',
|
||||
},
|
||||
[inWhere('& picture')]: {
|
||||
marginTop: '1.7em',
|
||||
marginBottom: '1.7em',
|
||||
},
|
||||
[inWhere('& picture > img')]: {
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
[inWhere('& video')]: {
|
||||
marginTop: '1.7em',
|
||||
marginBottom: '1.7em',
|
||||
},
|
||||
[inWhere('& kbd')]: {
|
||||
fontSize: '0.85em',
|
||||
borderRadius: 'xs',
|
||||
paddingTop: '0.15em',
|
||||
paddingBottom: '0.15em',
|
||||
paddingInlineEnd: '0.35em',
|
||||
paddingInlineStart: '0.35em',
|
||||
fontFamily: 'inherit',
|
||||
color: 'fg.muted',
|
||||
'--shadow': 'colors.border',
|
||||
boxShadow: '0 0 0 1px var(--shadow),0 1px 0 1px var(--shadow)',
|
||||
},
|
||||
[inWhere('& code')]: {
|
||||
fontSize: '0.925em',
|
||||
letterSpacing: '-0.01em',
|
||||
borderRadius: 'md',
|
||||
borderWidth: '1px',
|
||||
padding: '0.25em',
|
||||
},
|
||||
[inWhere('& pre code')]: {
|
||||
fontSize: 'inherit',
|
||||
letterSpacing: 'inherit',
|
||||
borderWidth: 'inherit',
|
||||
padding: '0',
|
||||
},
|
||||
[inWhere('& h2 code')]: {
|
||||
fontSize: '0.9em',
|
||||
},
|
||||
[inWhere('& h3 code')]: {
|
||||
fontSize: '0.8em',
|
||||
},
|
||||
[inWhere('& pre')]: {
|
||||
backgroundColor: 'bg.subtle',
|
||||
marginTop: '1.6em',
|
||||
marginBottom: '1.6em',
|
||||
borderRadius: 'md',
|
||||
fontSize: '0.9em',
|
||||
paddingTop: '0.65em',
|
||||
paddingBottom: '0.65em',
|
||||
paddingInlineEnd: '1em',
|
||||
paddingInlineStart: '1em',
|
||||
overflowX: 'auto',
|
||||
fontWeight: '400',
|
||||
},
|
||||
[inWhere('& ol')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
paddingInlineStart: '1.5em',
|
||||
},
|
||||
[inWhere('& ul')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
paddingInlineStart: '1.5em',
|
||||
},
|
||||
[inWhere('& li')]: {
|
||||
marginTop: '0.285em',
|
||||
marginBottom: '0.285em',
|
||||
},
|
||||
[inWhere('& ol > li')]: {
|
||||
paddingInlineStart: '0.4em',
|
||||
listStyleType: 'decimal',
|
||||
'&::marker': {
|
||||
color: 'fg.muted',
|
||||
},
|
||||
},
|
||||
[inWhere('& ul > li')]: {
|
||||
paddingInlineStart: '0.4em',
|
||||
listStyleType: 'disc',
|
||||
'&::marker': {
|
||||
color: 'fg.muted',
|
||||
},
|
||||
},
|
||||
[inWhere('& > ul > li p')]: {
|
||||
marginTop: '0.5em',
|
||||
marginBottom: '0.5em',
|
||||
},
|
||||
[inWhere('& > ul > li > p:first-of-type')]: {
|
||||
marginTop: '1em',
|
||||
},
|
||||
[inWhere('& > ul > li > p:last-of-type')]: {
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& > ol > li > p:first-of-type')]: {
|
||||
marginTop: '1em',
|
||||
},
|
||||
[inWhere('& > ol > li > p:last-of-type')]: {
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& ul ul, ul ol, ol ul, ol ol')]: {
|
||||
marginTop: '0.5em',
|
||||
marginBottom: '0.5em',
|
||||
},
|
||||
[inWhere('& dl')]: {
|
||||
marginTop: '1em',
|
||||
marginBottom: '1em',
|
||||
},
|
||||
[inWhere('& dt')]: {
|
||||
fontWeight: '600',
|
||||
marginTop: '1em',
|
||||
},
|
||||
[inWhere('& dd')]: {
|
||||
marginTop: '0.285em',
|
||||
paddingInlineStart: '1.5em',
|
||||
},
|
||||
[inWhere('& hr')]: {
|
||||
marginTop: '2.25em',
|
||||
marginBottom: '2.25em',
|
||||
},
|
||||
[inWhere('& :is(h1,h2,h3,h4,h5,hr) + *')]: {
|
||||
marginTop: '0',
|
||||
},
|
||||
[inWhere('& table')]: {
|
||||
width: '100%',
|
||||
tableLayout: 'auto',
|
||||
textAlign: 'start',
|
||||
lineHeight: '1.5em',
|
||||
marginTop: '2em',
|
||||
marginBottom: '2em',
|
||||
},
|
||||
[inWhere('& thead')]: {
|
||||
borderBottomWidth: '1px',
|
||||
color: 'fg',
|
||||
},
|
||||
[inWhere('& tbody tr')]: {
|
||||
borderBottomWidth: '1px',
|
||||
borderBottomColor: 'border',
|
||||
},
|
||||
[inWhere('& thead th')]: {
|
||||
paddingInlineEnd: '1em',
|
||||
paddingBottom: '0.65em',
|
||||
paddingInlineStart: '1em',
|
||||
fontWeight: 'medium',
|
||||
textAlign: 'start',
|
||||
},
|
||||
[inWhere('& thead th:first-of-type')]: {
|
||||
paddingInlineStart: '0',
|
||||
},
|
||||
[inWhere('& thead th:last-of-type')]: {
|
||||
paddingInlineEnd: '0',
|
||||
},
|
||||
[inWhere('& tbody td, tfoot td')]: {
|
||||
paddingTop: '0.65em',
|
||||
paddingInlineEnd: '1em',
|
||||
paddingBottom: '0.65em',
|
||||
paddingInlineStart: '1em',
|
||||
},
|
||||
[inWhere('& tbody td:first-of-type, tfoot td:first-of-type')]: {
|
||||
paddingInlineStart: '0',
|
||||
},
|
||||
[inWhere('& tbody td:last-of-type, tfoot td:last-of-type')]: {
|
||||
paddingInlineEnd: '0',
|
||||
},
|
||||
[inWhere('& figure')]: {
|
||||
marginTop: '1.625em',
|
||||
marginBottom: '1.625em',
|
||||
},
|
||||
[inWhere('& figure > *')]: {
|
||||
marginTop: '0',
|
||||
marginBottom: '0',
|
||||
},
|
||||
[inWhere('& figcaption')]: {
|
||||
fontSize: '0.85em',
|
||||
lineHeight: '1.25em',
|
||||
marginTop: '0.85em',
|
||||
color: 'fg.muted',
|
||||
},
|
||||
[inWhere('& h1, h2, h3, h4')]: {
|
||||
color: 'fg',
|
||||
fontWeight: '600',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
md: {
|
||||
fontSize: 'sm',
|
||||
},
|
||||
lg: {
|
||||
fontSize: 'md',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user