gg
Deploy Iddaai Frontend / build-and-deploy (push) Failing after 34s

This commit is contained in:
2026-05-10 22:59:27 +03:00
parent 6dadc5f613
commit 5c8619b282
161 changed files with 6708 additions and 3435 deletions
+40 -4
View File
@@ -194,7 +194,9 @@ export default function AdminContent() {
<StaggerItem>
<AdminStat
label={t("total-users")}
value={analytics?.totalUsers ?? analytics?.users?.total ?? 0}
value={
analytics?.totalUsers ?? analytics?.users?.total ?? 0
}
icon={<LuUsers />}
colorPalette="primary"
/>
@@ -202,15 +204,27 @@ export default function AdminContent() {
<StaggerItem>
<AdminStat
label={t("total-predictions")}
value={analytics?.totalPredictions ?? analytics?.predictions ?? 0}
value={
analytics?.totalPredictions ?? analytics?.predictions ?? 0
}
icon={<LuChartBar />}
colorPalette="green"
/>
</StaggerItem>
<StaggerItem>
<AdminStat
label={t("premium-users", { fallback: "Premium Users" })}
value={analytics?.users?.premium ?? 0}
icon={<LuShield />}
colorPalette="purple"
/>
</StaggerItem>
<StaggerItem>
<AdminStat
label={t("active-users")}
value={analytics?.activeUsers ?? analytics?.users?.active ?? 0}
value={
analytics?.activeUsers ?? analytics?.users?.active ?? 0
}
icon={<LuActivity />}
colorPalette="orange"
/>
@@ -253,6 +267,9 @@ export default function AdminContent() {
<Text flex={1} textAlign="center">
{t("user-role")}
</Text>
<Text flex={1} textAlign="center">
{t("subscription", { fallback: "Subscription" })}
</Text>
<Text flex={1} textAlign="center">
{t("user-status")}
</Text>
@@ -282,7 +299,9 @@ export default function AdminContent() {
</Text>
<Flex flex={1} justify="center">
<Badge
colorPalette={isAdminRole([user.role]) ? "red" : "gray"}
colorPalette={
isAdminRole([user.role]) ? "red" : "gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
@@ -290,6 +309,23 @@ export default function AdminContent() {
{formatRoleLabel(user.role)}
</Badge>
</Flex>
<Flex flex={1} justify="center">
<Badge
colorPalette={
user.subscriptionStatus === "premium"
? "purple"
: user.subscriptionStatus === "plus"
? "blue"
: "gray"
}
variant="subtle"
fontSize="2xs"
borderRadius="full"
textTransform="capitalize"
>
{user.subscriptionStatus || "free"}
</Badge>
</Flex>
<Flex flex={1} justify="center">
<Badge
colorPalette={user.isActive ? "green" : "gray"}
+1 -6
View File
@@ -41,12 +41,7 @@ export default function AnalysisContent() {
const toast = (opts: { title: string; status: string }) =>
toaster.create({
title: opts.title,
type: opts.status as
| "success"
| "warning"
| "error"
| "info"
| "loading",
type: opts.status as "success" | "warning" | "error" | "info" | "loading",
});
const toggleMatch = (id: string) => {
+5 -1
View File
@@ -50,7 +50,11 @@ interface LoginModalProps {
/* ────────────────────────── Component ────────────────────────── */
export function LoginModal({ open, onOpenChange, initialMode = "login" }: LoginModalProps) {
export function LoginModal({
open,
onOpenChange,
initialMode = "login",
}: LoginModalProps) {
const t = useTranslations();
const [mode, setMode] = useState<"login" | "register">(initialMode);
const [loading, setLoading] = useState(false);
+193 -164
View File
@@ -769,30 +769,51 @@ export default function CouponBuilderContent() {
{/* Engine Mode Toggle */}
<VStack align="stretch" gap={2} mb={4}>
<HStack gap={2}>
<Icon as={engineMode === "ai" ? LuSparkles : LuDatabase} color={engineMode === "ai" ? "teal.500" : "cyan.500"} />
<Text fontWeight="semibold" fontSize="sm">{t("engine-mode-label")}</Text>
<InfoIcon content={t("engine-mode-help")} label={t("engine-mode-label")} />
<Icon
as={engineMode === "ai" ? LuSparkles : LuDatabase}
color={engineMode === "ai" ? "teal.500" : "cyan.500"}
/>
<Text fontWeight="semibold" fontSize="sm">
{t("engine-mode-label")}
</Text>
<InfoIcon
content={t("engine-mode-help")}
label={t("engine-mode-label")}
/>
</HStack>
<HStack gap={2}>
<Badge
colorPalette={engineMode === "ai" ? "teal" : "gray"}
variant={engineMode === "ai" ? "solid" : "outline"}
cursor="pointer" px={3} py={1}
cursor="pointer"
px={3}
py={1}
onClick={() => setEngineMode("ai")}
>
<LuSparkles /> AI
</Badge>
<Badge
colorPalette={engineMode === "frequency" ? "cyan" : "gray"}
variant={engineMode === "frequency" ? "solid" : "outline"}
cursor="pointer" px={3} py={1}
colorPalette={
engineMode === "frequency" ? "cyan" : "gray"
}
variant={
engineMode === "frequency" ? "solid" : "outline"
}
cursor="pointer"
px={3}
py={1}
onClick={() => setEngineMode("frequency")}
>
<LuDatabase /> Frekans
</Badge>
</HStack>
<Text fontSize="xs" color={engineMode === "ai" ? "teal.500" : "cyan.500"}>
{engineMode === "ai" ? t("ai-mode-active") : t("freq-mode-active")}
<Text
fontSize="xs"
color={engineMode === "ai" ? "teal.500" : "cyan.500"}
>
{engineMode === "ai"
? t("ai-mode-active")
: t("freq-mode-active")}
</Text>
</VStack>
@@ -801,165 +822,173 @@ export default function CouponBuilderContent() {
{engineMode === "frequency" ? (
<FrequencyPanel />
) : (
<>
<Text
fontSize="xs"
color="fg.muted"
fontWeight="semibold"
mb={2}
>
{t("strategy")}
</Text>
<VStack align="stretch" gap={2} mb={4}>
{strategies.map((entry) => {
const active = activeStrategy === entry.key;
const palette = strategyPalette(entry.key);
return (
<Box
key={entry.key}
p={3}
borderWidth="1px"
borderColor={active ? `${palette}.400` : borderColor}
bg={active ? `${palette}.50` : mutedBg}
borderRadius="xl"
cursor="pointer"
onClick={() => setActiveStrategy(entry.key)}
>
<HStack justify="space-between" mb={1}>
<Badge
colorPalette={palette}
variant={active ? "solid" : "subtle"}
>
{entry.label}
</Badge>
{active ? <LuCheck color="currentColor" /> : null}
</HStack>
<Text fontSize="sm" color="fg.muted">
{entry.description}
</Text>
</Box>
);
})}
</VStack>
<Separator mb={4} />
{/* Match Count Input */}
<VStack align="stretch" gap={2} mb={4}>
<HStack justify="space-between">
<HStack gap={2}>
<Icon as={LuListChecks} color="purple.500" />
<Text fontWeight="semibold" fontSize="sm">
{t("match-count-label")}
</Text>
<InfoIcon
content={t("match-count-help")}
label={t("match-count-label")}
/>
</HStack>
<Badge colorPalette="purple" variant="subtle">
{matchCount}
</Badge>
</HStack>
<input
type="range"
min="2"
max="15"
value={matchCount}
onChange={(e) => setMatchCount(Number(e.target.value))}
style={{
width: "100%",
accentColor: "teal",
cursor: "pointer",
}}
/>
<HStack
justify="space-between"
fontSize="xs"
color="fg.muted"
>
<Text>2</Text>
<Text>
{t("match-count-auto", { count: allMatches.length })}
<>
<Text
fontSize="xs"
color="fg.muted"
fontWeight="semibold"
mb={2}
>
{t("strategy")}
</Text>
<Text>15</Text>
</HStack>
</VStack>
<Separator mb={4} />
<VStack align="stretch" gap={3} mb={4}>
<HStack justify="space-between">
<HStack gap={2}>
<Icon as={LuLayers3} color="teal.500" />
<Text fontWeight="semibold">
{t("selected-matches-panel-title")}
</Text>
</HStack>
<Badge colorPalette="teal" variant="subtle">
{selectedMatchIds.length}
</Badge>
</HStack>
{selectedMatches.length > 0 ? (
<VStack align="stretch" gap={2}>
{selectedMatches.map((match: MatchResponseDto) => (
<Flex
key={match.id}
p={3}
bg={mutedBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
justify="space-between"
align="center"
gap={3}
>
<VStack align="flex-start" gap={0}>
<Text fontWeight="semibold" fontSize="sm">
{match.matchName}
</Text>
<Text fontSize="xs" color="fg.muted">
{formatDate(match.mstUtc, locale)}
</Text>
</VStack>
<Button
variant="ghost"
size="xs"
colorPalette="red"
onClick={() => toggleMatchSelection(match.id)}
<VStack align="stretch" gap={2} mb={4}>
{strategies.map((entry) => {
const active = activeStrategy === entry.key;
const palette = strategyPalette(entry.key);
return (
<Box
key={entry.key}
p={3}
borderWidth="1px"
borderColor={
active ? `${palette}.400` : borderColor
}
bg={active ? `${palette}.50` : mutedBg}
borderRadius="xl"
cursor="pointer"
onClick={() => setActiveStrategy(entry.key)}
>
{t("remove-match")}
</Button>
</Flex>
))}
<HStack justify="space-between" mb={1}>
<Badge
colorPalette={palette}
variant={active ? "solid" : "subtle"}
>
{entry.label}
</Badge>
{active ? (
<LuCheck color="currentColor" />
) : null}
</HStack>
<Text fontSize="sm" color="fg.muted">
{entry.description}
</Text>
</Box>
);
})}
</VStack>
) : (
<Box p={3} bg={mutedBg} borderRadius="xl">
<Text fontSize="sm" color="fg.muted">
{t("selected-matches-empty")}
</Text>
</Box>
)}
</VStack>
<Button
variant="solid"
colorPalette="teal"
size="lg"
width="full"
borderRadius="xl"
loading={suggestCoupon.isPending}
onClick={handleSuggest}
>
<LuSparkles />
{t("ai-suggest")}
</Button>
<Text fontSize="xs" color="fg.muted" mt={3}>
{selectedMatchIds.length > 0
? t("manual-selection-helper")
: t("automatic-selection-helper")}
</Text>
</>
<Separator mb={4} />
{/* Match Count Input */}
<VStack align="stretch" gap={2} mb={4}>
<HStack justify="space-between">
<HStack gap={2}>
<Icon as={LuListChecks} color="purple.500" />
<Text fontWeight="semibold" fontSize="sm">
{t("match-count-label")}
</Text>
<InfoIcon
content={t("match-count-help")}
label={t("match-count-label")}
/>
</HStack>
<Badge colorPalette="purple" variant="subtle">
{matchCount}
</Badge>
</HStack>
<input
type="range"
min="2"
max="15"
value={matchCount}
onChange={(e) =>
setMatchCount(Number(e.target.value))
}
style={{
width: "100%",
accentColor: "teal",
cursor: "pointer",
}}
/>
<HStack
justify="space-between"
fontSize="xs"
color="fg.muted"
>
<Text>2</Text>
<Text>
{t("match-count-auto", {
count: allMatches.length,
})}
</Text>
<Text>15</Text>
</HStack>
</VStack>
<Separator mb={4} />
<VStack align="stretch" gap={3} mb={4}>
<HStack justify="space-between">
<HStack gap={2}>
<Icon as={LuLayers3} color="teal.500" />
<Text fontWeight="semibold">
{t("selected-matches-panel-title")}
</Text>
</HStack>
<Badge colorPalette="teal" variant="subtle">
{selectedMatchIds.length}
</Badge>
</HStack>
{selectedMatches.length > 0 ? (
<VStack align="stretch" gap={2}>
{selectedMatches.map((match: MatchResponseDto) => (
<Flex
key={match.id}
p={3}
bg={mutedBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
justify="space-between"
align="center"
gap={3}
>
<VStack align="flex-start" gap={0}>
<Text fontWeight="semibold" fontSize="sm">
{match.matchName}
</Text>
<Text fontSize="xs" color="fg.muted">
{formatDate(match.mstUtc, locale)}
</Text>
</VStack>
<Button
variant="ghost"
size="xs"
colorPalette="red"
onClick={() => toggleMatchSelection(match.id)}
>
{t("remove-match")}
</Button>
</Flex>
))}
</VStack>
) : (
<Box p={3} bg={mutedBg} borderRadius="xl">
<Text fontSize="sm" color="fg.muted">
{t("selected-matches-empty")}
</Text>
</Box>
)}
</VStack>
<Button
variant="solid"
colorPalette="teal"
size="lg"
width="full"
borderRadius="xl"
loading={suggestCoupon.isPending}
onClick={handleSuggest}
>
<LuSparkles />
{t("ai-suggest")}
</Button>
<Text fontSize="xs" color="fg.muted" mt={3}>
{selectedMatchIds.length > 0
? t("manual-selection-helper")
: t("automatic-selection-helper")}
</Text>
</>
)}
</Card.Body>
</Card.Root>
+19 -4
View File
@@ -170,7 +170,11 @@ export default function FrequencyPanel() {
max="95"
value={minSignal * 100}
onChange={(e) => setMinSignal(Number(e.target.value) / 100)}
style={{ width: "100%", accentColor: "#0891b2", cursor: "pointer" }}
style={{
width: "100%",
accentColor: "#0891b2",
cursor: "pointer",
}}
/>
<HStack justify="space-between" fontSize="xs" color="fg.muted">
<Text>50%</Text>
@@ -197,7 +201,11 @@ export default function FrequencyPanel() {
max="5"
value={maxMatches}
onChange={(e) => setMaxMatches(Number(e.target.value))}
style={{ width: "100%", accentColor: "#9333ea", cursor: "pointer" }}
style={{
width: "100%",
accentColor: "#9333ea",
cursor: "pointer",
}}
/>
<HStack justify="space-between" fontSize="xs" color="fg.muted">
<Text>2</Text>
@@ -325,7 +333,12 @@ export default function FrequencyPanel() {
borderRadius="xl"
bg={mutedBg}
>
<Flex justify="space-between" align="flex-start" gap={3} mb={3}>
<Flex
justify="space-between"
align="flex-start"
gap={3}
mb={3}
>
<VStack align="flex-start" gap={1}>
<Text fontWeight="bold">{bet.match_name}</Text>
<Text fontSize="sm" color="fg.muted">
@@ -405,7 +418,9 @@ export default function FrequencyPanel() {
<Box p={4} bg="orange.50" borderRadius="xl">
<HStack gap={2} mb={2}>
<Icon as={LuBadgeAlert} color="orange.500" />
<Text fontWeight="semibold">{t("rejected-matches-title")}</Text>
<Text fontWeight="semibold">
{t("rejected-matches-title")}
</Text>
</HStack>
<VStack align="stretch" gap={1}>
{result.rejected_matches.map((entry, i) => (
+71 -53
View File
@@ -14,7 +14,12 @@ import {
} 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 {
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";
@@ -26,8 +31,14 @@ 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";
import type {
LeagueWithMatchesDto,
MatchResponseDto,
} from "@/lib/api/matches/types";
import type {
MatchPredictionDto,
ValueBetDto,
} from "@/lib/api/predictions/types";
// ========================
// Stats Card
@@ -181,8 +192,11 @@ export default function DashboardContent() {
queryMatches.mutate({ sport: "football", limit: 20 });
}
const todayMatches: MatchResponseDto[] = queryMatches.data?.data?.flatMap((l: LeagueWithMatchesDto) => l.matches) ?? [];
const upcomingPredictions: MatchPredictionDto[] = upcomingData?.data?.matches ?? [];
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;
@@ -328,44 +342,46 @@ export default function DashboardContent() {
</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>
))}
{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
@@ -396,16 +412,18 @@ export default function DashboardContent() {
</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}
/>
))}
{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
+12 -2
View File
@@ -309,7 +309,11 @@ export default function HomeContent() {
shadow="lg"
>
<SimpleGrid columns={{ base: 2, md: 4 }} gap={6}>
<StatBlock value={15000} label={t("stats-predictions")} suffix="+" />
<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="+" />
@@ -320,7 +324,13 @@ export default function HomeContent() {
{/* Features Section */}
<Box mb={16}>
<ScrollScaleIn>
<Heading as="h2" size="xl" textAlign="center" mb={3} fontWeight="bold">
<Heading
as="h2"
size="xl"
textAlign="center"
mb={3}
fontWeight="bold"
>
{t("features-title")}
</Heading>
<Text
+81 -21
View File
@@ -13,6 +13,7 @@ import {
ClientOnly,
Text,
Separator,
Badge,
} from "@chakra-ui/react";
import { Link, useRouter } from "@/i18n/navigation";
import { ColorModeButton } from "@/components/ui/color-mode";
@@ -40,9 +41,11 @@ import { signOut, useSession } from "next-auth/react";
import { authConfig } from "@/config/auth";
import { LoginModal } from "@/components/auth/login-modal";
import { isAdminRole } from "@/lib/auth/roles";
import { LuLogIn, LuUser, LuShield, LuZap } from "react-icons/lu";
import { LuLogIn, LuUser, LuShield, LuZap, LuCrown } from "react-icons/lu";
import { PlanBadge } from "@/components/subscription";
import GlobalSearch from "@/components/search/global-search";
import Image from "next/image";
import { useGetMe } from "@/lib/api/users/use-hooks";
export default function Header() {
const t = useTranslations();
@@ -57,6 +60,8 @@ export default function Header() {
const isAuthenticated = !!session;
const isLoading = status === "loading";
const visibleItems = getVisibleNavItems(NAV_ITEMS, isAuthenticated);
const { data: meData } = useGetMe(isAuthenticated);
const usageLimit = meData?.data?.usageLimit;
useEffect(() => {
const handleScroll = () => setIsSticky(window.scrollY >= 10);
@@ -82,26 +87,54 @@ export default function Header() {
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 && isAdminRole(session.user.roles) && (
<MenuItem value="admin" onClick={() => router.push("/admin")}>
<LuShield />
{t("nav.admin")}
<HStack gap={2}>
{usageLimit && (
<Badge
size="sm"
colorPalette={
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
? "green"
: "red"
}
variant="subtle"
display={{ base: "none", sm: "inline-flex" }}
>
<LuZap style={{ marginRight: "4px" }} />
{usageLimit.maxAnalyses - usageLimit.analysisCount}{" "}
{t("common.limits.analysis_left", { defaultValue: "Analiz" })}
</Badge>
)}
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
<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>
)}
<MenuItem onClick={handleLogout} value="sign-out">
{t("auth.sign-out")}
</MenuItem>
</MenuContent>
</MenuRoot>
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
<MenuItem
value="pricing"
onClick={() => router.push("/pricing")}
>
<LuCrown />
{t("nav.pricing")}
</MenuItem>
)}
{session?.user && isAdminRole(session.user.roles) && (
<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>
</HStack>
);
}
@@ -143,9 +176,24 @@ export default function Header() {
variant="solid"
size="sm"
/>
<Text fontSize="sm" fontWeight="semibold" truncate>
<Text fontSize="sm" fontWeight="semibold" truncate flex={1}>
{session?.user?.name || session?.user?.email}
</Text>
{usageLimit && (
<Badge
size="sm"
colorPalette={
usageLimit.maxAnalyses - usageLimit.analysisCount > 0
? "green"
: "red"
}
variant="subtle"
>
<LuZap style={{ marginRight: "4px" }} />
{usageLimit.maxAnalyses - usageLimit.analysisCount}
</Badge>
)}
<PlanBadge plan={session?.user?.subscriptionPlan ?? "free"} />
</Flex>
<Button
variant="ghost"
@@ -157,6 +205,18 @@ export default function Header() {
<LuUser />
{t("nav.profile")}
</Button>
{(session?.user?.subscriptionPlan ?? "free") === "free" && (
<Button
variant="outline"
size="sm"
width="full"
colorPalette="primary"
onClick={() => router.push("/pricing")}
>
<LuCrown />
{t("nav.pricing")}
</Button>
)}
<Button
variant="surface"
size="sm"
+104 -21
View File
@@ -1,6 +1,15 @@
"use client";
import { Box, Flex, Heading, Text, VStack, HStack, Badge, Spinner } from "@chakra-ui/react";
import {
Box,
Flex,
Heading,
Text,
VStack,
HStack,
Badge,
Spinner,
} from "@chakra-ui/react";
import { Link as ChakraLink } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
@@ -12,7 +21,11 @@ import MatchList from "@/components/matches/match-list";
import { LuTrophy, LuMapPin, LuArrowLeft } from "react-icons/lu";
import { Link } from "@/i18n/navigation";
export default function LeagueDetailContent({ leagueId }: { leagueId: string }) {
export default function LeagueDetailContent({
leagueId,
}: {
leagueId: string;
}) {
const t = useTranslations("leagues");
const leagueQuery = useLeagueById(leagueId);
@@ -20,18 +33,19 @@ export default function LeagueDetailContent({ leagueId }: { leagueId: string })
const matchesQuery = useQuery({
queryKey: ["league-matches", leagueId, league?.sport],
queryFn: () => matchesService.queryMatches({
sport: league?.sport || "football",
leagueId: leagueId,
status: "Finished",
limit: 100,
}),
queryFn: () =>
matchesService.queryMatches({
sport: league?.sport || "football",
leagueId: leagueId,
status: "Finished",
limit: 100,
}),
enabled: !!league,
});
const bgGradient = useColorModeValue(
"linear(to-r, primary.500, primary.700)",
"linear(to-r, primary.600, primary.900)"
"linear(to-r, primary.600, primary.900)",
);
const flatMatches = matchesQuery.data?.data?.[0]?.matches || [];
@@ -39,32 +53,79 @@ export default function LeagueDetailContent({ leagueId }: { leagueId: string })
return (
<Box minH="calc(100vh - 80px)">
{/* Hero Section */}
<Box bgGradient={bgGradient} color="white" pt={16} pb={20} px={6} position="relative" overflow="hidden">
<Box position="absolute" top="-20%" right="-10%" opacity={0.1} transform="rotate(15deg)">
<Box
bgGradient={bgGradient}
color="white"
pt={16}
pb={20}
px={6}
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top="-20%"
right="-10%"
opacity={0.1}
transform="rotate(15deg)"
>
<LuTrophy size={400} />
</Box>
<Box maxW="7xl" mx="auto" position="relative" zIndex={1}>
<SlideUp>
<VStack align="flex-start" gap={4} maxW="3xl">
<ChakraLink as={Link} href="/leagues" color="whiteAlpha.900" _hover={{ color: "white" }} display="flex" alignItems="center" gap={2} mb={2} fontWeight="medium">
<ChakraLink
as={Link}
href="/leagues"
color="whiteAlpha.900"
_hover={{ color: "white" }}
display="flex"
alignItems="center"
gap={2}
mb={2}
fontWeight="medium"
>
<LuArrowLeft /> Liglere Dön
</ChakraLink>
{leagueQuery.isLoading ? (
<Spinner color="white" borderWidth="3px" size="xl" />
) : league ? (
<>
<HStack gap={3}>
<Badge colorScheme={league.sport === "football" ? "green" : "orange"} variant="solid" bg="whiteAlpha.300" size="lg" px={4} py={1} rounded="full">
<Badge
colorScheme={
league.sport === "football" ? "green" : "orange"
}
variant="solid"
bg="whiteAlpha.300"
size="lg"
px={4}
py={1}
rounded="full"
>
{league.sport}
</Badge>
{league.season && (
<Badge variant="outline" color="white" borderColor="whiteAlpha.400" size="lg" px={4} py={1} rounded="full">
<Badge
variant="outline"
color="white"
borderColor="whiteAlpha.400"
size="lg"
px={4}
py={1}
rounded="full"
>
SEZON: {league.season}
</Badge>
)}
</HStack>
<Heading as="h1" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="800" letterSpacing="tight">
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="800"
letterSpacing="tight"
>
{league.name}
</Heading>
<HStack fontSize="lg" color="whiteAlpha.900">
@@ -81,11 +142,33 @@ export default function LeagueDetailContent({ leagueId }: { leagueId: string })
</Box>
{/* Main Content Area */}
<Box maxW="7xl" mx="auto" px={6} mt={-10} position="relative" zIndex={2} pb={20}>
<SlideUp transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}>
<Box bg={useColorModeValue("white", "gray.900")} p={{ base: 4, md: 8 }} shadow="xl" borderRadius="2xl" borderWidth="1px" borderColor={useColorModeValue("gray.200", "gray.800")}>
<Heading size="md" mb={6}>Geçmiş Maçlar</Heading>
<MatchList flatMatches={flatMatches} isLoading={matchesQuery.isLoading || leagueQuery.isLoading} />
<Box
maxW="7xl"
mx="auto"
px={6}
mt={-10}
position="relative"
zIndex={2}
pb={20}
>
<SlideUp
transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
>
<Box
bg={useColorModeValue("white", "gray.900")}
p={{ base: 4, md: 8 }}
shadow="xl"
borderRadius="2xl"
borderWidth="1px"
borderColor={useColorModeValue("gray.200", "gray.800")}
>
<Heading size="md" mb={6}>
Geçmiş Maçlar
</Heading>
<MatchList
flatMatches={flatMatches}
isLoading={matchesQuery.isLoading || leagueQuery.isLoading}
/>
</Box>
</SlideUp>
</Box>
+491 -106
View File
@@ -24,7 +24,14 @@ import {
useSearchTeams,
} from "@/lib/api/leagues/use-hooks";
import type { CountryDto, LeagueDto, TeamDto } from "@/lib/api/leagues/types";
import { LuSearch, LuGlobe, LuTrophy, LuUsers, LuArrowRight, LuMapPin } from "react-icons/lu";
import {
LuSearch,
LuGlobe,
LuTrophy,
LuUsers,
LuArrowRight,
LuMapPin,
} from "react-icons/lu";
import { useMemo, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { Link } from "@/i18n/navigation";
@@ -37,17 +44,19 @@ export default function LeaguesContent() {
const bgGradient = useColorModeValue(
"linear(to-r, primary.500, primary.700)",
"linear(to-r, primary.600, primary.900)"
"linear(to-r, primary.600, primary.900)",
);
const cardBg = useColorModeValue("white", "gray.900");
const borderColor = useColorModeValue("gray.200", "gray.800");
const hoverBg = useColorModeValue("gray.50", "whiteAlpha.50");
const [activeTab, setActiveTab] = useState<"leagues" | "teams">("leagues");
const [sportFilter, setSportFilter] = useState<string>("");
const [selectedCountryId, setSelectedCountryId] = useState<string | null>(null);
const [selectedCountryId, setSelectedCountryId] = useState<string | null>(
null,
);
const [teamSearchQuery, setTeamSearchQuery] = useState("");
const debouncedTeamQuery = useDebounce(teamSearchQuery, 300);
@@ -67,21 +76,23 @@ export default function LeaguesContent() {
const filteredCountries = useMemo(() => {
if (!countries.data?.data) return [];
if (!debouncedCountryQuery) return countries.data.data;
return countries.data.data.filter((c) =>
c.name.toLowerCase().includes(debouncedCountryQuery.toLowerCase())
return countries.data.data.filter((c) =>
c.name.toLowerCase().includes(debouncedCountryQuery.toLowerCase()),
);
}, [countries.data?.data, debouncedCountryQuery]);
const displayedLeagues = useMemo(() => {
let sourceLeagues: LeagueDto[] = leagues.data?.data || [];
if (selectedCountryId) {
sourceLeagues = sourceLeagues.filter(l => l.countryId === selectedCountryId);
sourceLeagues = sourceLeagues.filter(
(l) => l.countryId === selectedCountryId,
);
}
// Apply sport filter if selected
if (sportFilter) {
return sourceLeagues.filter(l => l.sport === sportFilter);
return sourceLeagues.filter((l) => l.sport === sportFilter);
}
return sourceLeagues;
}, [selectedCountryId, leagues.data?.data, sportFilter]);
@@ -89,21 +100,55 @@ export default function LeaguesContent() {
return (
<Box minH="calc(100vh - 80px)">
{/* Hero Section */}
<Box bgGradient={bgGradient} color="white" pt={16} pb={20} px={6} position="relative" overflow="hidden">
<Box position="absolute" top="-20%" right="-10%" opacity={0.1} transform="rotate(15deg)">
<Box
bgGradient={bgGradient}
color="white"
pt={16}
pb={20}
px={6}
position="relative"
overflow="hidden"
>
<Box
position="absolute"
top="-20%"
right="-10%"
opacity={0.1}
transform="rotate(15deg)"
>
<LuTrophy size={400} />
</Box>
<Box maxW="7xl" mx="auto" position="relative" zIndex={1}>
<SlideUp>
<VStack align="center" gap={4} textAlign="center" maxW="3xl" mx="auto">
<Badge colorScheme="whiteAlpha" variant="subtle" size="lg" px={4} py={1} rounded="full">
<VStack
align="center"
gap={4}
textAlign="center"
maxW="3xl"
mx="auto"
>
<Badge
colorScheme="whiteAlpha"
variant="subtle"
size="lg"
px={4}
py={1}
rounded="full"
>
{t("title")}
</Badge>
<Heading as="h1" fontSize={{ base: "3xl", md: "5xl" }} fontWeight="800" letterSpacing="tight">
{activeTab === "leagues" ? t("countries-leagues") : tMatches("search-teams")}
<Heading
as="h1"
fontSize={{ base: "3xl", md: "5xl" }}
fontWeight="800"
letterSpacing="tight"
>
{activeTab === "leagues"
? t("countries-leagues")
: tMatches("search-teams")}
</Heading>
<Text fontSize="lg" color="whiteAlpha.800" maxW="xl">
{activeTab === "leagues"
{activeTab === "leagues"
? "Explore top football and basketball leagues around the world. Filter by country and analyze historical matches."
: "Find your favorite teams across all leagues. Get deep insights and head-to-head statistics."}
</Text>
@@ -113,17 +158,42 @@ export default function LeaguesContent() {
</Box>
{/* Main Content Area - Pulled up to overlap hero */}
<Box maxW="7xl" mx="auto" px={6} mt={-10} position="relative" zIndex={2} pb={20}>
<SlideUp transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}>
<Card.Root bg={cardBg} shadow="xl" borderRadius="2xl" borderWidth="1px" borderColor={borderColor} overflow="hidden">
<Box
maxW="7xl"
mx="auto"
px={6}
mt={-10}
position="relative"
zIndex={2}
pb={20}
>
<SlideUp
transition={{ delay: 0.1, duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
>
<Card.Root
bg={cardBg}
shadow="xl"
borderRadius="2xl"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
>
{/* Tab Navigation */}
<Flex borderBottomWidth="1px" borderColor={borderColor} bg={useColorModeValue("gray.50", "whiteAlpha.50")}>
<Flex
borderBottomWidth="1px"
borderColor={borderColor}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
>
<Flex flex={1}>
<Box
flex={1} py={4} textAlign="center" cursor="pointer"
<Box
flex={1}
py={4}
textAlign="center"
cursor="pointer"
borderBottomWidth="2px"
borderColor={activeTab === "leagues" ? "primary.500" : "transparent"}
borderColor={
activeTab === "leagues" ? "primary.500" : "transparent"
}
color={activeTab === "leagues" ? "primary.500" : "fg.muted"}
fontWeight={activeTab === "leagues" ? "bold" : "medium"}
onClick={() => setActiveTab("leagues")}
@@ -135,10 +205,15 @@ export default function LeaguesContent() {
<Text>{t("countries-leagues")}</Text>
</HStack>
</Box>
<Box
flex={1} py={4} textAlign="center" cursor="pointer"
<Box
flex={1}
py={4}
textAlign="center"
cursor="pointer"
borderBottomWidth="2px"
borderColor={activeTab === "teams" ? "primary.500" : "transparent"}
borderColor={
activeTab === "teams" ? "primary.500" : "transparent"
}
color={activeTab === "teams" ? "primary.500" : "fg.muted"}
fontWeight={activeTab === "teams" ? "bold" : "medium"}
onClick={() => setActiveTab("teams")}
@@ -156,67 +231,148 @@ export default function LeaguesContent() {
{/* LEAGUES TAB */}
{activeTab === "leagues" && (
<Flex direction={{ base: "column", lg: "row" }} minH="600px">
{/* Left Sidebar: Countries */}
<Box w={{ base: "full", lg: "320px" }} borderRightWidth={{ lg: "1px" }} borderColor={borderColor} bg={useColorModeValue("gray.50", "whiteAlpha.50")}>
<Box
w={{ base: "full", lg: "320px" }}
borderRightWidth={{ lg: "1px" }}
borderColor={borderColor}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
>
<VStack align="stretch" h="full" gap={0}>
<Box p={4} borderBottomWidth="1px" borderColor={borderColor} bg={cardBg}>
<InputGroup startElement={<LuSearch color="gray.400" />} w="full">
<Input
placeholder={t("countries") + "..."}
variant="subtle"
<Box
p={4}
borderBottomWidth="1px"
borderColor={borderColor}
bg={cardBg}
>
<InputGroup
startElement={<LuSearch color="gray.400" />}
w="full"
>
<Input
placeholder={t("countries") + "..."}
variant="subtle"
borderRadius="full"
value={countrySearchQuery}
onChange={(e) => setCountrySearchQuery(e.target.value)}
onChange={(e) =>
setCountrySearchQuery(e.target.value)
}
/>
</InputGroup>
</Box>
<Box flex={1} overflowY="auto" maxH={{ base: "300px", lg: "600px" }} p={2}>
<Box
flex={1}
overflowY="auto"
maxH={{ base: "300px", lg: "600px" }}
p={2}
>
{countries.isLoading ? (
<Flex justify="center" py={10}><Spinner color="primary.500" /></Flex>
<Flex justify="center" py={10}>
<Spinner color="primary.500" />
</Flex>
) : (
<VStack gap={1} align="stretch">
<Box
px={4} py={3} borderRadius="lg" cursor="pointer"
bg={selectedCountryId === null ? "primary.500" : "transparent"}
px={4}
py={3}
borderRadius="lg"
cursor="pointer"
bg={
selectedCountryId === null
? "primary.500"
: "transparent"
}
color={selectedCountryId === null ? "white" : "fg"}
_hover={{ bg: selectedCountryId === null ? "primary.600" : hoverBg }}
_hover={{
bg:
selectedCountryId === null
? "primary.600"
: hoverBg,
}}
onClick={() => setSelectedCountryId(null)}
transition="all 0.2s"
>
<HStack justify="space-between">
<HStack gap={3}>
<LuGlobe />
<Text fontWeight={selectedCountryId === null ? "bold" : "medium"}>{t("all")}</Text>
<Text
fontWeight={
selectedCountryId === null
? "bold"
: "medium"
}
>
{t("all")}
</Text>
</HStack>
<Badge size="sm" bg={selectedCountryId === null ? "whiteAlpha.300" : "gray.100"} color={selectedCountryId === null ? "white" : "fg"}>
<Badge
size="sm"
bg={
selectedCountryId === null
? "whiteAlpha.300"
: "gray.100"
}
color={
selectedCountryId === null ? "white" : "fg"
}
>
{leagues.data?.data?.length || 0}
</Badge>
</HStack>
</Box>
{filteredCountries.map((country: CountryDto) => {
const isSelected = selectedCountryId === country.id;
return (
<Box
key={country.id}
px={4} py={3} borderRadius="lg" cursor="pointer"
px={4}
py={3}
borderRadius="lg"
cursor="pointer"
bg={isSelected ? "primary.500" : "transparent"}
color={isSelected ? "white" : "fg"}
_hover={{ bg: isSelected ? "primary.600" : hoverBg }}
_hover={{
bg: isSelected ? "primary.600" : hoverBg,
}}
onClick={() => setSelectedCountryId(country.id)}
transition="all 0.2s"
>
<HStack justify="space-between">
<HStack gap={3}>
{country.flag ? (
<img src={country.flag} width="20" height="20" style={{ borderRadius: "50%", objectFit: "cover" }} alt={country.name} />
) : <LuMapPin />}
<Text fontWeight={isSelected ? "bold" : "medium"}>{country.name}</Text>
<img
src={country.flag}
width="20"
height="20"
style={{
borderRadius: "50%",
objectFit: "cover",
}}
alt={country.name}
/>
) : (
<LuMapPin />
)}
<Text
fontWeight={
isSelected ? "bold" : "medium"
}
>
{country.name}
</Text>
</HStack>
<Badge size="sm" bg={isSelected ? "whiteAlpha.300" : "gray.100"} color={isSelected ? "white" : "fg"}>
{leagues.data?.data?.filter(l => l.countryId === country.id).length || 0}
<Badge
size="sm"
bg={
isSelected ? "whiteAlpha.300" : "gray.100"
}
color={isSelected ? "white" : "fg"}
>
{leagues.data?.data?.filter(
(l) => l.countryId === country.id,
).length || 0}
</Badge>
</HStack>
</Box>
@@ -231,44 +387,99 @@ export default function LeaguesContent() {
{/* Right Area: Leagues Grid */}
<Box flex={1} p={{ base: 4, md: 8 }} bg={cardBg}>
{/* Top Filters */}
<Flex justify="space-between" align="center" mb={6} direction={{ base: "column", sm: "row" }} gap={4}>
<Flex
justify="space-between"
align="center"
mb={6}
direction={{ base: "column", sm: "row" }}
gap={4}
>
<Heading size="md" fontWeight="bold">
{selectedCountryId
? `${countries.data?.data?.find(c => c.id === selectedCountryId)?.name} ${t("leagues")}`
{selectedCountryId
? `${countries.data?.data?.find((c) => c.id === selectedCountryId)?.name} ${t("leagues")}`
: t("leagues")}
<Text as="span" color="fg.muted" ml={2} fontWeight="normal" fontSize="sm">
<Text
as="span"
color="fg.muted"
ml={2}
fontWeight="normal"
fontSize="sm"
>
({displayedLeagues.length})
</Text>
</Heading>
<HStack gap={2} bg={useColorModeValue("gray.100", "gray.800")} p={1} borderRadius="full">
<HStack
gap={2}
bg={useColorModeValue("gray.100", "gray.800")}
p={1}
borderRadius="full"
>
<Box
px={4} py={1.5} borderRadius="full" cursor="pointer" fontSize="sm" fontWeight="medium"
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
fontSize="sm"
fontWeight="medium"
bg={!sportFilter ? "white" : "transparent"}
color={!sportFilter ? "black" : "fg.muted"}
shadow={!sportFilter ? "sm" : "none"}
onClick={() => setSportFilter("")}
transition="all 0.2s"
_dark={{ bg: !sportFilter ? "gray.600" : "transparent", color: !sportFilter ? "white" : "gray.400" }}
_dark={{
bg: !sportFilter ? "gray.600" : "transparent",
color: !sportFilter ? "white" : "gray.400",
}}
>
{t("all")}
</Box>
<Box
px={4} py={1.5} borderRadius="full" cursor="pointer" fontSize="sm" fontWeight="medium"
bg={sportFilter === "football" ? "green.500" : "transparent"}
color={sportFilter === "football" ? "white" : "fg.muted"}
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
fontSize="sm"
fontWeight="medium"
bg={
sportFilter === "football"
? "green.500"
: "transparent"
}
color={
sportFilter === "football" ? "white" : "fg.muted"
}
shadow={sportFilter === "football" ? "sm" : "none"}
onClick={() => setSportFilter(sportFilter === "football" ? "" : "football")}
onClick={() =>
setSportFilter(
sportFilter === "football" ? "" : "football",
)
}
transition="all 0.2s"
>
{tMatches("football")}
</Box>
<Box
px={4} py={1.5} borderRadius="full" cursor="pointer" fontSize="sm" fontWeight="medium"
bg={sportFilter === "basketball" ? "orange.500" : "transparent"}
color={sportFilter === "basketball" ? "white" : "fg.muted"}
px={4}
py={1.5}
borderRadius="full"
cursor="pointer"
fontSize="sm"
fontWeight="medium"
bg={
sportFilter === "basketball"
? "orange.500"
: "transparent"
}
color={
sportFilter === "basketball" ? "white" : "fg.muted"
}
shadow={sportFilter === "basketball" ? "sm" : "none"}
onClick={() => setSportFilter(sportFilter === "basketball" ? "" : "basketball")}
onClick={() =>
setSportFilter(
sportFilter === "basketball" ? "" : "basketball",
)
}
transition="all 0.2s"
>
{tMatches("basketball")}
@@ -278,17 +489,46 @@ export default function LeaguesContent() {
{/* Leagues Grid */}
{leagues.isLoading ? (
<Flex justify="center" py={20}><Spinner size="xl" color="primary.500" borderWidth="3px" /></Flex>
<Flex justify="center" py={20}>
<Spinner
size="xl"
color="primary.500"
borderWidth="3px"
/>
</Flex>
) : displayedLeagues.length === 0 ? (
<Flex direction="column" align="center" justify="center" py={20} textAlign="center">
<Box bg="gray.100" _dark={{ bg: "gray.800" }} p={6} borderRadius="full" mb={4}>
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Box
bg="gray.100"
_dark={{ bg: "gray.800" }}
p={6}
borderRadius="full"
mb={4}
>
<LuTrophy size={40} color="gray" />
</Box>
<Heading size="md" mb={2}>Bulunamadı</Heading>
<Text color="fg.muted">Seçili kriterlere uygun lig bulunamadı.</Text>
<Heading size="md" mb={2}>
Bulunamadı
</Heading>
<Text color="fg.muted">
Seçili kriterlere uygun lig bulunamadı.
</Text>
</Flex>
) : (
<Grid templateColumns={{ base: "1fr", md: "repeat(2, 1fr)", xl: "repeat(3, 1fr)" }} gap={4}>
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
xl: "repeat(3, 1fr)",
}}
gap={4}
>
{displayedLeagues.map((league: LeagueDto) => (
<GridItem key={league.id}>
<ChakraLink
@@ -311,27 +551,88 @@ export default function LeaguesContent() {
color="inherit"
data-group
>
<Flex justify="space-between" align="flex-start" mb={4}>
<Box p={2} borderRadius="lg" bg={league.sport === "football" ? "green.50" : "orange.50"} _dark={{ bg: league.sport === "football" ? "green.900" : "orange.900" }}>
<LuTrophy size={20} color={league.sport === "football" ? "var(--chakra-colors-green-500)" : "var(--chakra-colors-orange-500)"} />
<Flex
justify="space-between"
align="flex-start"
mb={4}
>
<Box
p={2}
borderRadius="lg"
bg={
league.sport === "football"
? "green.50"
: "orange.50"
}
_dark={{
bg:
league.sport === "football"
? "green.900"
: "orange.900",
}}
>
<LuTrophy
size={20}
color={
league.sport === "football"
? "var(--chakra-colors-green-500)"
: "var(--chakra-colors-orange-500)"
}
/>
</Box>
<Badge size="sm" variant="subtle" colorScheme={league.sport === "football" ? "green" : "orange"}>
<Badge
size="sm"
variant="subtle"
colorScheme={
league.sport === "football"
? "green"
: "orange"
}
>
{league.sport}
</Badge>
</Flex>
<Heading size="sm" mb={1} lineClamp={1} _groupHover={{ color: "primary.500" }}>
<Heading
size="sm"
mb={1}
lineClamp={1}
_groupHover={{ color: "primary.500" }}
>
{league.name}
</Heading>
<HStack color="fg.muted" fontSize="sm" gap={1}>
<LuMapPin size={14} />
<Text lineClamp={1}>{league.country?.name || "Global"}</Text>
<Text lineClamp={1}>
{league.country?.name || "Global"}
</Text>
</HStack>
{league.season && (
<Flex mt={4} pt={4} borderTopWidth="1px" borderColor={borderColor} justify="space-between" align="center">
<Text fontSize="xs" color="fg.muted" fontWeight="medium">SEZON: {league.season}</Text>
<Icon as={LuArrowRight} color="gray.400" _groupHover={{ color: "primary.500", transform: "translateX(4px)" }} transition="all 0.2s" />
<Flex
mt={4}
pt={4}
borderTopWidth="1px"
borderColor={borderColor}
justify="space-between"
align="center"
>
<Text
fontSize="xs"
color="fg.muted"
fontWeight="medium"
>
SEZON: {league.season}
</Text>
<Icon
as={LuArrowRight}
color="gray.400"
_groupHover={{
color: "primary.500",
transform: "translateX(4px)",
}}
transition="all 0.2s"
/>
</Flex>
)}
</ChakraLink>
@@ -347,7 +648,10 @@ export default function LeaguesContent() {
{activeTab === "teams" && (
<Box p={{ base: 4, md: 8 }}>
<Box maxW="2xl" mx="auto" mb={10}>
<InputGroup startElement={<LuSearch color="gray.400" size={20} />} w="full">
<InputGroup
startElement={<LuSearch color="gray.400" size={20} />}
w="full"
>
<Input
placeholder={tMatches("search-teams") + "..."}
value={teamSearchQuery}
@@ -357,30 +661,70 @@ export default function LeaguesContent() {
fontSize="lg"
py={6}
boxShadow="sm"
_focus={{ boxShadow: "0 0 0 2px var(--chakra-colors-primary-500)" }}
_focus={{
boxShadow: "0 0 0 2px var(--chakra-colors-primary-500)",
}}
/>
</InputGroup>
</Box>
{debouncedTeamQuery.length < 2 ? (
<Flex direction="column" align="center" justify="center" py={20} textAlign="center">
<Box bg="primary.50" _dark={{ bg: "primary.900" }} p={8} borderRadius="full" mb={6}>
<LuUsers size={64} color="var(--chakra-colors-primary-500)" />
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Box
bg="primary.50"
_dark={{ bg: "primary.900" }}
p={8}
borderRadius="full"
mb={6}
>
<LuUsers
size={64}
color="var(--chakra-colors-primary-500)"
/>
</Box>
<Heading size="lg" mb={3}>{t("search-at-least-2")}</Heading>
<Heading size="lg" mb={3}>
{t("search-at-least-2")}
</Heading>
<Text color="fg.muted" maxW="md">
Find detailed statistics, upcoming matches, and head-to-head analysis by searching for any team worldwide.
Find detailed statistics, upcoming matches, and
head-to-head analysis by searching for any team worldwide.
</Text>
</Flex>
) : searchTeams.isLoading ? (
<Flex justify="center" py={20}><Spinner size="xl" color="primary.500" borderWidth="3px" /></Flex>
<Flex justify="center" py={20}>
<Spinner size="xl" color="primary.500" borderWidth="3px" />
</Flex>
) : searchTeams.data?.data?.length === 0 ? (
<Flex direction="column" align="center" justify="center" py={20} textAlign="center">
<Heading size="md" mb={2}>Takım Bulunamadı</Heading>
<Text color="fg.muted">"{debouncedTeamQuery}" aramasıyla eşleşen bir takım bulunamadı.</Text>
<Flex
direction="column"
align="center"
justify="center"
py={20}
textAlign="center"
>
<Heading size="md" mb={2}>
Takım Bulunamadı
</Heading>
<Text color="fg.muted">
"{debouncedTeamQuery}" aramasıyla eşleşen bir takım
bulunamadı.
</Text>
</Flex>
) : (
<Grid templateColumns={{ base: "1fr", md: "repeat(2, 1fr)", xl: "repeat(3, 1fr)" }} gap={4}>
<Grid
templateColumns={{
base: "1fr",
md: "repeat(2, 1fr)",
xl: "repeat(3, 1fr)",
}}
gap={4}
>
{searchTeams.data?.data?.map((team: TeamDto) => (
<GridItem key={team.id}>
<ChakraLink
@@ -404,24 +748,65 @@ export default function LeaguesContent() {
data-group
>
{team.logo ? (
<Box w={12} h={12} borderRadius="full" overflow="hidden" flexShrink={0} mr={4} bg="white" p={1} shadow="sm">
<img src={team.logo} width="100%" height="100%" style={{ objectFit: "contain" }} alt={team.name} />
<Box
w={12}
h={12}
borderRadius="full"
overflow="hidden"
flexShrink={0}
mr={4}
bg="white"
p={1}
shadow="sm"
>
<img
src={team.logo}
width="100%"
height="100%"
style={{ objectFit: "contain" }}
alt={team.name}
/>
</Box>
) : (
<Flex w={12} h={12} borderRadius="full" bg="gray.100" _dark={{ bg: "gray.700" }} align="center" justify="center" flexShrink={0} mr={4}>
<Flex
w={12}
h={12}
borderRadius="full"
bg="gray.100"
_dark={{ bg: "gray.700" }}
align="center"
justify="center"
flexShrink={0}
mr={4}
>
<LuUsers size={20} color="gray" />
</Flex>
)}
<VStack align="start" gap={0} flex={1}>
<Heading size="sm" lineClamp={1} _groupHover={{ color: "primary.500" }}>{team.name}</Heading>
<Heading
size="sm"
lineClamp={1}
_groupHover={{ color: "primary.500" }}
>
{team.name}
</Heading>
<HStack color="fg.muted" fontSize="xs" gap={1}>
<LuMapPin size={12} />
<Text lineClamp={1}>{team.country || "Global"}</Text>
<Text lineClamp={1}>
{team.country || "Global"}
</Text>
</HStack>
</VStack>
<Badge ml={2} size="sm" colorScheme={team.sport === "football" ? "green" : "orange"} variant="subtle">
<Badge
ml={2}
size="sm"
colorScheme={
team.sport === "football" ? "green" : "orange"
}
variant="subtle"
>
{team.sport}
</Badge>
</ChakraLink>
+10 -10
View File
@@ -1,13 +1,6 @@
"use client";
import {
Box,
Flex,
Text,
Badge,
Image,
ScrollArea,
} from "@chakra-ui/react";
import { Box, Flex, Text, Badge, Image, ScrollArea } from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import type { ActiveLeagueDto } from "@/lib/api/matches/types";
@@ -68,7 +61,9 @@ export default function LeagueFilterBar({
py={2}
borderRadius="full"
borderWidth="1.5px"
borderColor={selectedLeagueId === null ? activeBorder : chipBorder}
borderColor={
selectedLeagueId === null ? activeBorder : chipBorder
}
bg={selectedLeagueId === null ? activeBg : chipBg}
cursor="pointer"
flexShrink={0}
@@ -133,7 +128,12 @@ export default function LeagueFilterBar({
) : null}
{/* League name + country */}
<Flex direction="column" align="flex-start" gap={0} lineHeight="1">
<Flex
direction="column"
align="flex-start"
gap={0}
lineHeight="1"
>
<Text
fontSize="xs"
fontWeight={isActive ? "bold" : "medium"}
+27 -11
View File
@@ -12,7 +12,13 @@ import {
Icon,
} from "@chakra-ui/react";
import { useColorModeValue } from "@/components/ui/color-mode";
import { LuUsers, LuUser, LuInfo, LuShieldCheck, LuClock } from "react-icons/lu";
import {
LuUsers,
LuUser,
LuInfo,
LuShieldCheck,
LuClock,
} from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
import type { MatchPredictionDto } from "@/lib/api/predictions/types";
@@ -79,10 +85,18 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
const meta = getLineupSourceMeta(source);
// Fallback: If no starting players are marked, but we have players, treat them as probable XI
if (homeLineups.length === 0 && match.lineups?.home && match.lineups.home.length > 0) {
if (
homeLineups.length === 0 &&
match.lineups?.home &&
match.lineups.home.length > 0
) {
homeLineups = match.lineups.home.slice(0, 11);
}
if (awayLineups.length === 0 && match.lineups?.away && match.lineups.away.length > 0) {
if (
awayLineups.length === 0 &&
match.lineups?.away &&
match.lineups.away.length > 0
) {
awayLineups = match.lineups.away.slice(0, 11);
}
@@ -99,10 +113,7 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
{meta.title}
</Text>
</HStack>
<Badge
colorPalette={meta.badgeColor}
variant="subtle"
>
<Badge colorPalette={meta.badgeColor} variant="subtle">
{meta.badge}
</Badge>
</Flex>
@@ -271,10 +282,15 @@ export default function LineupsCard({ match, prediction }: LineupsCardProps) {
<Text fontWeight="semibold" color="fg.muted">
Kadro Henüz Açıklanmadı
</Text>
<Text fontSize="sm" color="fg.subtle" textAlign="center" maxW="sm">
{match.homeTeamName} ve {match.awayTeamName} kadroları maç saatine
yakın güncellenecektir. AI analizi, takım istatistikleri ve güç
dengesi üzerinden yapılmaktadır.
<Text
fontSize="sm"
color="fg.subtle"
textAlign="center"
maxW="sm"
>
{match.homeTeamName} ve {match.awayTeamName} kadroları maç
saatine yakın güncellenecektir. AI analizi, takım istatistikleri
ve güç dengesi üzerinden yapılmaktadır.
</Text>
</VStack>
</Flex>
+104 -24
View File
@@ -21,10 +21,20 @@ import { useColorModeValue } from "@/components/ui/color-mode";
import { SlideUp, FadeIn } from "@/components/motion";
import { useMatchDetails } from "@/lib/api/matches/use-hooks";
import { usePrediction } from "@/lib/api/predictions/use-hooks";
import { useGetMe } from "@/lib/api/users/use-hooks";
import { useQueryClient } from "@tanstack/react-query";
import { UsersQueryKeys } from "@/lib/api/users/use-hooks";
import PredictionCard from "@/components/matches/prediction-card";
import OddsCard from "@/components/matches/odds-card";
import LineupsCard from "@/components/matches/lineups-card";
import { LuArrowLeft, LuRefreshCw, LuShield, LuFlag, LuUser } from "react-icons/lu";
import {
LuArrowLeft,
LuRefreshCw,
LuShield,
LuFlag,
LuUser,
LuSparkles,
} from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
// ─────────────────────────────────────────────────
@@ -60,6 +70,10 @@ interface SidelinedData {
export default function MatchDetailContent() {
const t = useTranslations("matches");
const tPred = useTranslations("predictions");
const queryClient = useQueryClient();
const { data: meData } = useGetMe();
const usageLimit = meData?.data?.usageLimit;
const hasLimit = usageLimit ? (usageLimit.maxAnalyses - usageLimit.analysisCount > 0) : true;
const tCommon = useTranslations("common");
const params = useParams();
const router = useRouter();
@@ -70,9 +84,16 @@ export default function MatchDetailContent() {
const {
data: predictionData,
isLoading: predLoading,
refetch: refetchPrediction,
refetch: refetchPredictionRaw,
isFetching: isPredFetching,
} = usePrediction(matchId);
const refetchPrediction = async () => {
await refetchPredictionRaw();
// After refetching, update the limits in the header
queryClient.invalidateQueries({ queryKey: UsersQueryKeys.me() });
};
const headerBg = useColorModeValue("white", "gray.800");
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
@@ -139,7 +160,13 @@ export default function MatchDetailContent() {
>
{/* League Banner */}
{match.league && (
<Box bg={subtleBg} px={4} py={2.5} borderBottomWidth="1px" borderColor={borderColor}>
<Box
bg={subtleBg}
px={4}
py={2.5}
borderBottomWidth="1px"
borderColor={borderColor}
>
<Flex justify="center" align="center" gap={2}>
{match.league.country?.flag && (
<Image
@@ -151,7 +178,8 @@ export default function MatchDetailContent() {
/>
)}
<Text fontSize="sm" fontWeight="semibold" color="fg.muted">
{match.league.country?.name && `${match.league.country.name}`}
{match.league.country?.name &&
`${match.league.country.name}`}
{match.league.name}
</Text>
<Badge
@@ -300,7 +328,10 @@ export default function MatchDetailContent() {
>
<LuUser size={14} />
<Text fontSize="xs" color="fg.muted">
{t("referee")}: <Text as="span" fontWeight="semibold" color="fg">{match.refereeName}</Text>
{t("referee")}:{" "}
<Text as="span" fontWeight="semibold" color="fg">
{match.refereeName}
</Text>
</Text>
</Flex>
)}
@@ -312,7 +343,12 @@ export default function MatchDetailContent() {
{/* ═══════════════════════════════════════════ */}
{hasSidelined && (
<FadeIn>
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl" mb={6}>
<Card.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Body>
<Heading as="h2" size="md" mb={4}>
🏥 {t("sidelined")}
@@ -361,6 +397,7 @@ export default function MatchDetailContent() {
variant="outline"
size="sm"
onClick={() => refetchPrediction()}
disabled={!hasLimit}
gap={1.5}
>
<LuRefreshCw />
@@ -368,7 +405,7 @@ export default function MatchDetailContent() {
</Button>
</Flex>
{predLoading ? (
{predLoading || isPredFetching ? (
<Flex justify="center" py={10}>
<Spinner size="md" color="primary.500" />
</Flex>
@@ -377,8 +414,21 @@ export default function MatchDetailContent() {
) : (
<Card.Root borderColor={borderColor} borderRadius="xl">
<Card.Body>
<Flex justify="center" align="center" py={8}>
<Text color="fg.muted">{tPred("no-predictions")}</Text>
<Flex direction="column" justify="center" align="center" py={8} gap={4}>
<Text color="fg.muted">{tPred("no-predictions", { defaultValue: "Tahmin bulunmuyor." })}</Text>
<Button
colorPalette="primary"
onClick={() => refetchPrediction()}
disabled={!hasLimit}
loading={isPredFetching}
>
<LuSparkles /> {tPred("generate", { defaultValue: "Yapay Zeka ile Analiz Et" })}
</Button>
{!hasLimit && (
<Text fontSize="sm" color="red.500">
{tCommon("limits.out_of_analysis", { defaultValue: "Günlük analiz limitiniz doldu." })}
</Text>
)}
</Flex>
</Card.Body>
</Card.Root>
@@ -409,15 +459,31 @@ interface SidelinedColumnProps {
t: ReturnType<typeof useTranslations>;
}
function SidelinedColumn({ team, teamName, teamLogo, injuryBg, injuryBorder, t }: SidelinedColumnProps) {
function SidelinedColumn({
team,
teamName,
teamLogo,
injuryBg,
injuryBorder,
t,
}: SidelinedColumnProps) {
const players = team?.players || [];
if (players.length === 0) {
return (
<Box>
<HStack gap={2} mb={3}>
{teamLogo && <Image src={teamLogo} alt={teamName} boxSize="20px" objectFit="contain" />}
<Text fontSize="sm" fontWeight="bold">{teamName}</Text>
{teamLogo && (
<Image
src={teamLogo}
alt={teamName}
boxSize="20px"
objectFit="contain"
/>
)}
<Text fontSize="sm" fontWeight="bold">
{teamName}
</Text>
</HStack>
<Text fontSize="xs" color="fg.muted" fontStyle="italic">
{t("no-sidelined")}
@@ -429,9 +495,23 @@ function SidelinedColumn({ team, teamName, teamLogo, injuryBg, injuryBorder, t }
return (
<Box>
<HStack gap={2} mb={3}>
{teamLogo && <Image src={teamLogo} alt={teamName} boxSize="20px" objectFit="contain" />}
<Text fontSize="sm" fontWeight="bold">{teamName}</Text>
<Badge colorPalette="red" variant="subtle" fontSize="2xs" borderRadius="full">
{teamLogo && (
<Image
src={teamLogo}
alt={teamName}
boxSize="20px"
objectFit="contain"
/>
)}
<Text fontSize="sm" fontWeight="bold">
{teamName}
</Text>
<Badge
colorPalette="red"
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{players.length}
</Badge>
</HStack>
@@ -458,21 +538,21 @@ function SidelinedColumn({ team, teamName, teamLogo, injuryBg, injuryBorder, t }
</Badge>
)}
<Text fontSize="xs" color="fg.muted">
{player.description || (
player.type === "injury"
{player.description ||
(player.type === "injury"
? t("injury")
: player.type === "suspended"
? t("suspended")
: t("other-reason")
)}
: t("other-reason"))}
</Text>
</HStack>
</VStack>
{player.matchesMissed !== undefined && player.matchesMissed > 0 && (
<Badge colorPalette="red" variant="subtle" fontSize="2xs">
{player.matchesMissed} {t("matches-missed")}
</Badge>
)}
{player.matchesMissed !== undefined &&
player.matchesMissed > 0 && (
<Badge colorPalette="red" variant="subtle" fontSize="2xs">
{player.matchesMissed} {t("matches-missed")}
</Badge>
)}
</Flex>
</Box>
))}
+12 -2
View File
@@ -3,7 +3,11 @@
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 {
StaggerContainer,
StaggerItem,
ScrollSlideUp,
} from "@/components/motion";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import MatchCard from "./match-card";
import type {
@@ -53,7 +57,13 @@ function MatchCardSkeleton() {
</HStack>
{/* League */}
<Flex mt={3} pt={2} borderTopWidth="1px" borderColor={border} justify="center">
<Flex
mt={3}
pt={2}
borderTopWidth="1px"
borderColor={border}
justify="center"
>
<Skeleton height="12px" width="120px" />
</Flex>
</Box>
+30 -13
View File
@@ -4,7 +4,12 @@ import { Box, Flex, Heading, Group, Button } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { SlideUp } from "@/components/motion";
import { SportFilter, LeagueSidebar, LeagueFilterBar, MatchList } from "@/components/matches";
import {
SportFilter,
LeagueSidebar,
LeagueFilterBar,
MatchList,
} from "@/components/matches";
import { useQueryMatches, useActiveLeagues } from "@/lib/api/matches/use-hooks";
import { useMatchStore } from "@/lib/stores/match-store";
@@ -17,7 +22,7 @@ export default function MatchesContent() {
const leagueFilter = useMatchStore((s) => s.leagueFilter);
const setSport = useMatchStore((s) => s.setSport);
const setLeague = useMatchStore((s) => s.setLeague);
const [quickFilter, setQuickFilter] = useState<QuickFilter>("all");
const [dateFilter, setDateFilter] = useState<string>("");
@@ -37,7 +42,12 @@ export default function MatchesContent() {
};
})();
const triggerQuery = (currentSport: typeof sport, currentLeague: string | null, currentFilter: QuickFilter, currentDate?: string) => {
const triggerQuery = (
currentSport: typeof sport,
currentLeague: string | null,
currentFilter: QuickFilter,
currentDate?: string,
) => {
const payload: any = {
sport: currentSport,
leagueId: currentLeague || undefined,
@@ -107,35 +117,42 @@ export default function MatchesContent() {
</Flex>
{/* Quick Filters */}
<Flex mb={6} overflowX="auto" pb={2} css={{ "&::-webkit-scrollbar": { display: "none" } }} gap={4} align="center">
<Flex
mb={6}
overflowX="auto"
pb={2}
css={{ "&::-webkit-scrollbar": { display: "none" } }}
gap={4}
align="center"
>
<Group attached>
<Button
<Button
size="sm"
onClick={() => handleQuickFilterChange("all")}
onClick={() => handleQuickFilterChange("all")}
colorPalette={quickFilter === "all" ? "primary" : "gray"}
variant={quickFilter === "all" ? "solid" : "outline"}
>
{t("all-matches")}
</Button>
<Button
<Button
size="sm"
onClick={() => handleQuickFilterChange("today")}
onClick={() => handleQuickFilterChange("today")}
colorPalette={quickFilter === "today" ? "primary" : "gray"}
variant={quickFilter === "today" ? "solid" : "outline"}
>
{t("today-matches")}
</Button>
<Button
<Button
size="sm"
onClick={() => handleQuickFilterChange("live")}
onClick={() => handleQuickFilterChange("live")}
colorPalette={quickFilter === "live" ? "primary" : "gray"}
variant={quickFilter === "live" ? "solid" : "outline"}
>
{t("live")}
</Button>
<Button
<Button
size="sm"
onClick={() => handleQuickFilterChange("next_1_hour")}
onClick={() => handleQuickFilterChange("next_1_hour")}
colorPalette={quickFilter === "next_1_hour" ? "primary" : "gray"}
variant={quickFilter === "next_1_hour" ? "solid" : "outline"}
>
@@ -163,7 +180,7 @@ export default function MatchesContent() {
fontSize: "0.875rem",
background: "transparent",
color: "inherit",
outline: "none"
outline: "none",
}}
/>
</Flex>
+371 -122
View File
@@ -52,30 +52,54 @@ function formatReasonFallback(reason: string): string {
if (evMatch) return `Teorik avantaj sinyali: Not ${evMatch[2]}`;
const negMatch = reason.match(/^negative_model_edge_([+\-][\d.]+)$/);
if (negMatch) return `Model avantajı negatif (${negMatch[1]})`;
const thresholdMatch = reason.match(/^below_market_edge_threshold_([+\-]?[\d.]+)$/);
if (thresholdMatch) return `Piyasa avantaj eşiğinin altında (${thresholdMatch[1]})`;
if (reason === "confidence_interval_too_wide") return "Güven aralığı fazla geniş.";
const thresholdMatch = reason.match(
/^below_market_edge_threshold_([+\-]?[\d.]+)$/,
);
if (thresholdMatch)
return `Piyasa avantaj eşiğinin altında (${thresholdMatch[1]})`;
if (reason === "confidence_interval_too_wide")
return "Güven aralığı fazla geniş.";
if (reason === "confidence_band_low") return "Güven bandı düşük.";
if (reason === "draw_probability_elevated") return "Beraberlik olasılığı yükselmiş görünüyor.";
if (reason === "balanced_match_risk") return "Maç dengeli görünüyor, sürpriz riski var.";
if (reason === "high_total_goal_volatility") return "Yüksek gol temposu sürpriz riskini artırıyor.";
if (reason === "mutual_goal_pressure") return "İki takım da gol tehdidi üretiyor.";
if (reason === "late_goal_swing_risk") return "Geç gol veya skor kırılması riski yüksek.";
if (reason === "live_match_open_state") return "Canlı maç tamamen açık oyuna dönmüş durumda.";
if (reason === "live_match_active_state") return "Canlı maç beklenenden daha hareketli ilerliyor.";
if (reason === "live_state_impossible_market") return "Canlı maç durumu bu marketi geçersiz kılıyor.";
if (reason === "live_score_exceeds_under_line") return "Canlı skor, alt seçeneğinin üst sınırına çok yaklaştı veya geçti.";
if (reason === "score_model_conflicts_with_under_pick") return "Skor ve xG modeli bu alt seçeneğiyle çelişiyor.";
if (reason === "score_model_conflicts_with_over_pick") return "Skor ve xG modeli bu üst seçeneğiyle çelişiyor.";
if (reason === "market_stack_conflict_over25") return "2.5 üst sinyali bu marketle çelişiyor.";
if (reason === "market_stack_conflict_btts") return "KG Var sinyali bu marketle çelişiyor.";
if (reason === "live_total_goals_close_to_line") return "Canlı toplam gol sayısı bu çizgiye fazla yaklaştı.";
if (reason === "score_model_conflicts_with_btts_no") return "Skor ve xG modeli KG Yok seçeneğiyle çelişiyor.";
if (reason === "score_model_conflicts_with_draw_pick") return "Skor modeli beraberlik seçeneğini desteklemiyor.";
if (reason === "score_model_conflicts_with_home_pick") return "Skor modeli ev sahibi seçeneğini desteklemiyor.";
if (reason === "score_model_conflicts_with_away_pick") return "Skor modeli deplasman seçeneğini desteklemiyor.";
if (reason === "draw_probability_elevated")
return "Beraberlik olasılığı yükselmiş görünüyor.";
if (reason === "balanced_match_risk")
return "Maç dengeli görünüyor, sürpriz riski var.";
if (reason === "high_total_goal_volatility")
return "Yüksek gol temposu sürpriz riskini artırıyor.";
if (reason === "mutual_goal_pressure")
return "İki takım da gol tehdidi üretiyor.";
if (reason === "late_goal_swing_risk")
return "Geç gol veya skor kırılması riski yüksek.";
if (reason === "live_match_open_state")
return "Canlı maç tamamen açık oyuna dönmüş durumda.";
if (reason === "live_match_active_state")
return "Canlı maç beklenenden daha hareketli ilerliyor.";
if (reason === "live_state_impossible_market")
return "Canlı maç durumu bu marketi geçersiz kılıyor.";
if (reason === "live_score_exceeds_under_line")
return "Canlı skor, alt seçeneğinin üst sınırına çok yaklaştı veya geçti.";
if (reason === "score_model_conflicts_with_under_pick")
return "Skor ve xG modeli bu alt seçeneğiyle çelişiyor.";
if (reason === "score_model_conflicts_with_over_pick")
return "Skor ve xG modeli bu üst seçeneğiyle çelişiyor.";
if (reason === "market_stack_conflict_over25")
return "2.5 üst sinyali bu marketle çelişiyor.";
if (reason === "market_stack_conflict_btts")
return "KG Var sinyali bu marketle çelişiyor.";
if (reason === "live_total_goals_close_to_line")
return "Canlı toplam gol sayısı bu çizgiye fazla yaklaştı.";
if (reason === "score_model_conflicts_with_btts_no")
return "Skor ve xG modeli KG Yok seçeneğiyle çelişiyor.";
if (reason === "score_model_conflicts_with_draw_pick")
return "Skor modeli beraberlik seçeneğini desteklemiyor.";
if (reason === "score_model_conflicts_with_home_pick")
return "Skor modeli ev sahibi seçeneğini desteklemiyor.";
if (reason === "score_model_conflicts_with_away_pick")
return "Skor modeli deplasman seçeneğini desteklemiyor.";
if (/^[a-z0-9_]+$/i.test(reason)) {
return reason.replace(/_/g, " ").replace(/^\w/, (char) => char.toUpperCase());
return reason
.replace(/_/g, " ")
.replace(/^\w/, (char) => char.toUpperCase());
}
return reason;
}
@@ -101,7 +125,8 @@ function formatEdgeSignal(value?: number): string {
}
function getEdgePalette(value?: number): string {
if (value === undefined || value === null || Number.isNaN(value)) return "gray";
if (value === undefined || value === null || Number.isNaN(value))
return "gray";
if (value <= 0) return "red";
if (value < 0.08) return "yellow";
if (value < 0.15) return "orange";
@@ -211,7 +236,10 @@ function getMarketLabel(
market: string,
marketLabels?: Record<string, string>,
): string {
if (marketLabels && Object.prototype.hasOwnProperty.call(marketLabels, market)) {
if (
marketLabels &&
Object.prototype.hasOwnProperty.call(marketLabels, market)
) {
return marketLabels[market];
}
@@ -278,7 +306,13 @@ function getPredictionSport(prediction: MatchPredictionDto): SportType {
return "football";
}
const SIGNAL_TIER_ORDER: SignalTier[] = ["CORE", "VALUE", "LEAN", "LONGSHOT", "PASS"];
const SIGNAL_TIER_ORDER: SignalTier[] = [
"CORE",
"VALUE",
"LEAN",
"LONGSHOT",
"PASS",
];
function getSignalTierPalette(tier?: SignalTier) {
switch (tier) {
@@ -318,7 +352,12 @@ function TooltipIcon({ content }: { content: string }) {
positioning={{ placement: "top" }}
contentProps={{ maxW: "260px", fontSize: "xs", px: 3, py: 2 }}
>
<IconButton aria-label="Bilgi" variant="ghost" size="2xs" colorPalette="gray">
<IconButton
aria-label="Bilgi"
variant="ghost"
size="2xs"
colorPalette="gray"
>
<LuCircleHelp />
</IconButton>
</Tooltip>
@@ -361,7 +400,13 @@ function MetricTile({
const bg = useColorModeValue("gray.50", "whiteAlpha.50");
const borderColor = useColorModeValue("gray.200", "gray.700");
return (
<Box p={3.5} bg={bg} borderWidth="1px" borderColor={borderColor} borderRadius="xl">
<Box
p={3.5}
bg={bg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
>
<HStack justify="space-between" mb={1.5}>
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
{label}
@@ -388,7 +433,12 @@ function Bar({
}) {
return (
<Box h={height} w="full" bg={trackBg} borderRadius="full" overflow="hidden">
<Box h="full" w={`${Math.max(0, Math.min(100, value))}%`} bg={color} borderRadius="full" />
<Box
h="full"
w={`${Math.max(0, Math.min(100, value))}%`}
bg={color}
borderRadius="full"
/>
</Box>
);
}
@@ -484,7 +534,12 @@ function PickCard({
return (
<Card.Root bg={bg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<Flex justify="space-between" align={{ base: "start", md: "center" }} direction={{ base: "column", md: "row" }} gap={3}>
<Flex
justify="space-between"
align={{ base: "start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
>
<VStack align="start" gap={2}>
<Badge colorPalette={palette} variant="solid" borderRadius="full">
{title}
@@ -493,30 +548,50 @@ function PickCard({
{pick.pick}
</Text>
<HStack gap={2} flexWrap="wrap">
<Badge variant="subtle">{getMarketLabel(pick.market, marketLabels)}</Badge>
<Badge colorPalette={pick.playable ? "green" : "gray"} variant="subtle">
<Badge variant="subtle">
{getMarketLabel(pick.market, marketLabels)}
</Badge>
<Badge
colorPalette={pick.playable ? "green" : "gray"}
variant="subtle"
>
{pick.bet_grade}
</Badge>
<Badge colorPalette={getSignalTierPalette(pick.signal_tier)} variant="subtle">
<Badge
colorPalette={getSignalTierPalette(pick.signal_tier)}
variant="subtle"
>
{getSignalTierLabel(pick.signal_tier)}
</Badge>
<Badge colorPalette={confidenceBandPalette} variant="subtle">
{getConfidenceBandLabel(pick.confidence_interval?.band)}
</Badge>
<Badge colorPalette={getEdgePalette(pick.ev_edge)} variant="subtle">
<Badge
colorPalette={getEdgePalette(pick.ev_edge)}
variant="subtle"
>
Teorik avantaj {formatEdgeSignal(pick.ev_edge)}
</Badge>
</HStack>
</VStack>
<SimpleGrid columns={2} gap={3} minW={{ base: "full", md: "320px" }}>
<MetricTile label={labels.confidence} value={formatPercent(pick.calibrated_confidence, 0)} />
<MetricTile
label={labels.confidence}
value={formatPercent(pick.calibrated_confidence, 0)}
/>
<MetricTile label={labels.odds} value={formatOdds(pick.odds)} />
<MetricTile
label={labels.recommendedStake}
value={formatUnits(pick.stake_units || stakeFallback)}
/>
<MetricTile label={labels.playScore} value={formatSignalScore(pick.play_score)} />
<MetricTile label="Guven Araligi" value={formatInterval(pick.confidence_interval)} />
<MetricTile
label={labels.playScore}
value={formatSignalScore(pick.play_score)}
/>
<MetricTile
label="Guven Araligi"
value={formatInterval(pick.confidence_interval)}
/>
<MetricTile
label="Band"
value={getConfidenceBandLabel(pick.confidence_interval?.band)}
@@ -524,7 +599,10 @@ function PickCard({
/>
</SimpleGrid>
</Flex>
<ProbabilitySplit modelProb={pick.probability} impliedProb={pick.implied_prob} />
<ProbabilitySplit
modelProb={pick.probability}
impliedProb={pick.implied_prob}
/>
<Box>
<HStack justify="space-between" mb={1.5}>
<Text fontSize="sm" fontWeight="semibold">
@@ -540,7 +618,10 @@ function PickCard({
trackBg={trackBg}
/>
</Box>
<ReasonList items={pick.decision_reasons} resolveReason={resolveReason} />
<ReasonList
items={pick.decision_reasons}
resolveReason={resolveReason}
/>
{pick.confidence_interval && !pick.confidence_interval.threshold_met ? (
<Box
p={3}
@@ -550,7 +631,8 @@ function PickCard({
borderColor={intervalWarningBorder}
>
<Text fontSize="sm" color="fg.muted">
Guven araligi genis. Sinyal olsa bile tek basina oynanmasi onerilmez.
Guven araligi genis. Sinyal olsa bile tek basina oynanmasi
onerilmez.
</Text>
</Box>
) : null}
@@ -582,48 +664,73 @@ function SummaryTable({
{items
.slice()
.sort((left, right) => {
const leftIndex = SIGNAL_TIER_ORDER.indexOf(left.signal_tier || "PASS");
const rightIndex = SIGNAL_TIER_ORDER.indexOf(right.signal_tier || "PASS");
const leftIndex = SIGNAL_TIER_ORDER.indexOf(
left.signal_tier || "PASS",
);
const rightIndex = SIGNAL_TIER_ORDER.indexOf(
right.signal_tier || "PASS",
);
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
return right.calibrated_confidence - left.calibrated_confidence;
})
.map((item) => (
<Flex
key={`${item.market}-${item.pick}`}
justify="space-between"
align={{ base: "start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
px={3}
py={3}
borderRadius="xl"
bg={item.playable ? highlightBg : "transparent"}
borderWidth="1px"
borderColor={item.playable ? "green.200" : borderColor}
>
<HStack gap={2} flexWrap="wrap">
<Badge colorPalette={item.playable ? "green" : "gray"} variant="subtle">
{item.bet_grade}
</Badge>
<Badge colorPalette={getSignalTierPalette(item.signal_tier)} variant="subtle">
{getSignalTierLabel(item.signal_tier)}
</Badge>
<Text fontWeight="semibold">{getMarketLabel(item.market, marketLabels)}</Text>
<Text color="fg.muted">{item.pick}</Text>
</HStack>
<HStack gap={5} fontSize="sm">
<Text minW="48px">{formatOdds(item.odds)}</Text>
<Text minW="96px" color={`${getEdgePalette(item.ev_edge)}.500`} fontWeight="semibold">
{formatEdgeSignal(item.ev_edge)}
</Text>
<Text minW="48px">{formatPercent(item.calibrated_confidence, 0)}</Text>
<Badge colorPalette={getConfidenceBandPalette(item.confidence_interval?.band)} variant="subtle">
{getConfidenceBandLabel(item.confidence_interval?.band)}
</Badge>
<Badge variant="surface">{formatUnits(item.stake_units)}</Badge>
</HStack>
</Flex>
))}
<Flex
key={`${item.market}-${item.pick}`}
justify="space-between"
align={{ base: "start", md: "center" }}
direction={{ base: "column", md: "row" }}
gap={3}
px={3}
py={3}
borderRadius="xl"
bg={item.playable ? highlightBg : "transparent"}
borderWidth="1px"
borderColor={item.playable ? "green.200" : borderColor}
>
<HStack gap={2} flexWrap="wrap">
<Badge
colorPalette={item.playable ? "green" : "gray"}
variant="subtle"
>
{item.bet_grade}
</Badge>
<Badge
colorPalette={getSignalTierPalette(item.signal_tier)}
variant="subtle"
>
{getSignalTierLabel(item.signal_tier)}
</Badge>
<Text fontWeight="semibold">
{getMarketLabel(item.market, marketLabels)}
</Text>
<Text color="fg.muted">{item.pick}</Text>
</HStack>
<HStack gap={5} fontSize="sm">
<Text minW="48px">{formatOdds(item.odds)}</Text>
<Text
minW="96px"
color={`${getEdgePalette(item.ev_edge)}.500`}
fontWeight="semibold"
>
{formatEdgeSignal(item.ev_edge)}
</Text>
<Text minW="48px">
{formatPercent(item.calibrated_confidence, 0)}
</Text>
<Badge
colorPalette={getConfidenceBandPalette(
item.confidence_interval?.band,
)}
variant="subtle"
>
{getConfidenceBandLabel(item.confidence_interval?.band)}
</Badge>
<Badge variant="surface">
{formatUnits(item.stake_units)}
</Badge>
</HStack>
</Flex>
))}
</VStack>
</Card.Body>
</Card.Root>
@@ -650,7 +757,9 @@ function MarketBoardSection({
if (!marketBoard || !Object.keys(marketBoard).length) return null;
const summaryByMarket = new Map((betSummary || []).map((item) => [item.market, item]));
const summaryByMarket = new Map(
(betSummary || []).map((item) => [item.market, item]),
);
const orderedEntries = Object.entries(marketBoard).sort(([left], [right]) => {
const leftIndex = MARKET_ORDER.indexOf(left);
const rightIndex = MARKET_ORDER.indexOf(right);
@@ -662,11 +771,7 @@ function MarketBoardSection({
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
<Card.Body gap={4}>
<SectionTitle
icon={LuChartNoAxesCombined}
title={title}
info={info}
/>
<SectionTitle icon={LuChartNoAxesCombined} title={title} info={info} />
<SimpleGrid columns={{ base: 1, xl: 2 }} gap={4}>
{orderedEntries.map(([market, entry]) => {
if (!entry?.probs) return null;
@@ -690,21 +795,33 @@ function MarketBoardSection({
</Text>
<HStack gap={2} flexWrap="wrap">
{summary ? (
<Badge colorPalette={summary.playable ? "green" : "gray"} variant="subtle">
<Badge
colorPalette={summary.playable ? "green" : "gray"}
variant="subtle"
>
{summary.playable ? "Oynanabilir" : "Riskli"}
</Badge>
) : null}
{summary?.signal_tier ? (
<Badge colorPalette={getSignalTierPalette(summary.signal_tier)} variant="subtle">
<Badge
colorPalette={getSignalTierPalette(
summary.signal_tier,
)}
variant="subtle"
>
{getSignalTierLabel(summary.signal_tier)}
</Badge>
) : null}
{summary?.bet_grade ? <Badge variant="outline">{summary.bet_grade}</Badge> : null}
{summary?.bet_grade ? (
<Badge variant="outline">{summary.bet_grade}</Badge>
) : null}
</HStack>
</VStack>
{entry.pick ? (
<Badge
colorPalette={getConfidenceBandPalette(entry.confidence_band || interval?.band)}
colorPalette={getConfidenceBandPalette(
entry.confidence_band || interval?.band,
)}
variant="subtle"
borderRadius="full"
>
@@ -720,7 +837,11 @@ function MarketBoardSection({
/>
<MetricTile
label="Kalibre Guven"
value={summary ? formatPercent(summary.calibrated_confidence, 0) : "-"}
value={
summary
? formatPercent(summary.calibrated_confidence, 0)
: "-"
}
accent={summary?.playable ? "green.500" : "orange.500"}
/>
<MetricTile
@@ -807,7 +928,14 @@ function ScoreCard({
</SimpleGrid>
<SimpleGrid columns={{ base: 2, md: 5 }} gap={2}>
{prediction.scenario_top5.map((scenario) => (
<Box key={`${scenario.score}-${scenario.prob}`} p={3} bg={subBg} borderWidth="1px" borderColor={borderColor} borderRadius="xl">
<Box
key={`${scenario.score}-${scenario.prob}`}
p={3}
bg={subBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
>
<Text fontSize="lg" fontWeight="bold">
{scenario.score}
</Text>
@@ -835,7 +963,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
const ui = messages.predictions?.ui;
const uiText = (key: string, fallback: string) => ui?.[key] || fallback;
const resolveReason = (reason: string) =>
getPredictionReasonText(reason, messages.predictions?.["prediction-reasons"]);
getPredictionReasonText(
reason,
messages.predictions?.["prediction-reasons"],
);
const pageBg = useColorModeValue("gray.50", "gray.900");
const cardBg = useColorModeValue("white", "gray.800");
@@ -864,7 +995,13 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
value: prediction.engine_breakdown.player,
color: "green.400",
},
{ key: "odds", icon: LuTrendingUp, label: "Oran Analizi", value: prediction.engine_breakdown.odds, color: "orange.400" },
{
key: "odds",
icon: LuTrendingUp,
label: "Oran Analizi",
value: prediction.engine_breakdown.odds,
color: "orange.400",
},
{
key: "referee",
icon: LuShieldAlert,
@@ -894,30 +1031,50 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
borderRadius="xl"
>
<HStack align="start" gap={2}>
<Icon as={LuShieldAlert} boxSize={4.5} color="orange.500" mt={0.5} />
<Icon
as={LuShieldAlert}
boxSize={4.5}
color="orange.500"
mt={0.5}
/>
<Text fontSize="sm" color="fg.muted" lineHeight="tall">
Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi nedeniyle yanılabilir.
Bu bir model sinyalidir; kesin sonuç, garanti veya tutma yüzdesi
değildir. Sinyal puanı maç içi varyans, kadro ve veri kalitesi
nedeniyle yanılabilir.
</Text>
</HStack>
</Box>
{recommendedPick ? (
<Grid templateColumns={{ base: "1fr", xl: "1.4fr 1fr" }} gap={4}>
<Box p={4} bg={useColorModeValue("green.50", "green.950")} borderWidth="1px" borderColor={useColorModeValue("green.200", "green.800")} borderRadius="2xl">
<Box
p={4}
bg={useColorModeValue("green.50", "green.950")}
borderWidth="1px"
borderColor={useColorModeValue("green.200", "green.800")}
borderRadius="2xl"
>
<HStack justify="space-between" align="start" mb={4}>
<VStack align="start" gap={2}>
<Badge colorPalette="green" variant="solid" borderRadius="full">
<Badge
colorPalette="green"
variant="solid"
borderRadius="full"
>
{uiText("main-recommendation", "Öne Çıkan Sinyal")}
</Badge>
<Text fontSize="2xl" fontWeight="bold">
{recommendedPick.pick}
</Text>
<Text fontSize="sm" color="fg.muted">
{getMarketLabel(recommendedPick.market, marketLabels)} {uiText("best-market-copy", "marketinde en guclu secim.")}
{getMarketLabel(recommendedPick.market, marketLabels)}{" "}
{uiText("best-market-copy", "marketinde en guclu secim.")}
</Text>
<HStack gap={2} flexWrap="wrap">
<Badge colorPalette={mainBandPalette} variant="subtle">
{getConfidenceBandLabel(prediction.bet_advice.confidence_band)}
{getConfidenceBandLabel(
prediction.bet_advice.confidence_band,
)}
</Badge>
{recommendedPick.confidence_interval ? (
<Badge variant="outline">
@@ -929,9 +1086,21 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
<Icon as={LuBadgeCheck} boxSize={8} color="green.500" />
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} gap={3}>
<MetricTile label={uiText("confidence-label", "Guven")} value={formatPercent(recommendedPick.calibrated_confidence, 0)} />
<MetricTile label={uiText("odds-label", "Oran")} value={formatOdds(recommendedPick.odds)} />
<MetricTile label="Guven Araligi" value={formatInterval(recommendedPick.confidence_interval)} />
<MetricTile
label={uiText("confidence-label", "Guven")}
value={formatPercent(
recommendedPick.calibrated_confidence,
0,
)}
/>
<MetricTile
label={uiText("odds-label", "Oran")}
value={formatOdds(recommendedPick.odds)}
/>
<MetricTile
label="Guven Araligi"
value={formatInterval(recommendedPick.confidence_interval)}
/>
<MetricTile
label={uiText("edge-label", "Teorik Avantaj")}
value={formatEdgeSignal(recommendedPick.ev_edge)}
@@ -955,7 +1124,13 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
</SimpleGrid>
</Box>
<Box p={4} bg={cardBg} borderWidth="1px" borderColor={borderColor} borderRadius="2xl">
<Box
p={4}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="2xl"
>
<Text fontSize="sm" fontWeight="semibold" mb={3}>
{uiText("quick-read", "Hizli yorum")}
</Text>
@@ -985,12 +1160,18 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
/>
<MetricTile
label={uiText("lineup-source", "Lineup Kaynagi")}
value={getLineupSourceLabel(prediction.data_quality.lineup_source)}
value={getLineupSourceLabel(
prediction.data_quality.lineup_source,
)}
/>
<MetricTile
label={uiText("model-label", "Model")}
value={prediction.model_version}
/>
<MetricTile label={uiText("model-label", "Model")} value={prediction.model_version} />
</SimpleGrid>
{prediction.risk.is_surprise_risk || prediction.risk.warnings?.length ? (
{prediction.risk.is_surprise_risk ||
prediction.risk.warnings?.length ? (
<Box
p={4}
bg={useColorModeValue("orange.50", "orange.950")}
@@ -999,7 +1180,12 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
borderRadius="2xl"
>
<HStack align="start" gap={3}>
<Icon as={LuTriangleAlert} boxSize={5} color="orange.500" mt={0.5} />
<Icon
as={LuTriangleAlert}
boxSize={5}
color="orange.500"
mt={0.5}
/>
<VStack align="start" gap={1.5}>
<Text fontWeight="semibold">Risk Yorumu</Text>
<Text fontSize="sm" color="fg.muted">
@@ -1009,8 +1195,13 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
: "Model bu maçta ekstra dikkat istiyor.")}
</Text>
{prediction.risk.surprise_score !== undefined ? (
<Text fontSize="sm" fontWeight="semibold" color="orange.600">
Sürpriz skoru: {formatPercent(prediction.risk.surprise_score, 0)}
<Text
fontSize="sm"
fontWeight="semibold"
color="orange.600"
>
Sürpriz skoru:{" "}
{formatPercent(prediction.risk.surprise_score, 0)}
</Text>
) : null}
<ReasonList
@@ -1032,11 +1223,21 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
<SectionTitle
icon={LuChartColumn}
title={t("engine-breakdown-title")}
info={uiText("engine-info", "Tahmini en cok hangi bilesenlerin etkiledigini gosterir.")}
info={uiText(
"engine-info",
"Tahmini en cok hangi bilesenlerin etkiledigini gosterir.",
)}
/>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
{engineItems.map((item) => (
<Box key={item.key} p={4} bg={useColorModeValue("gray.50", "whiteAlpha.50")} borderWidth="1px" borderColor={borderColor} borderRadius="xl">
<Box
key={item.key}
p={4}
bg={useColorModeValue("gray.50", "whiteAlpha.50")}
borderWidth="1px"
borderColor={borderColor}
borderRadius="xl"
>
<HStack justify="space-between" mb={2}>
<HStack gap={2}>
<Icon as={item.icon} boxSize={4} color={item.color} />
@@ -1048,7 +1249,11 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
+{item.value.toFixed(1)}
</Text>
</HStack>
<Bar value={Math.min(item.value, 100)} color={item.color} trackBg={useColorModeValue("gray.100", "gray.700")} />
<Bar
value={Math.min(item.value, 100)}
color={item.color}
trackBg={useColorModeValue("gray.100", "gray.700")}
/>
</Box>
))}
</SimpleGrid>
@@ -1079,14 +1284,21 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
<SectionTitle
icon={LuFlame}
title={uiText("alternative-markets", "Alternatif Marketler")}
info={uiText("alternative-markets-info", "Ana tahmin disindaki secenekler.")}
info={uiText(
"alternative-markets-info",
"Ana tahmin disindaki secenekler.",
)}
/>
<SimpleGrid columns={{ base: 1, xl: 2 }} gap={4}>
{prediction.supporting_picks.map((pick) => (
<PickCard
key={`${pick.market}-${pick.pick}`}
pick={pick}
title={pick.playable ? uiText("alternative", "Alternatif") : uiText("pass-market", "PASS market")}
title={
pick.playable
? uiText("alternative", "Alternatif")
: uiText("pass-market", "PASS market")
}
resolveReason={resolveReason}
palette={pick.ev_edge > 0 ? "blue" : "orange"}
marketLabels={marketLabels}
@@ -1108,7 +1320,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
items={prediction.bet_summary || []}
marketLabels={marketLabels}
title={uiText("all-markets-title", "Tum Marketler")}
info={uiText("all-markets-info", "Butun secenekleri tek tabloda karsilastir.")}
info={uiText(
"all-markets-info",
"Butun secenekleri tek tabloda karsilastir.",
)}
/>
<ScoreCard prediction={prediction} sport={sport} />
<MarketBoardSection
@@ -1116,7 +1331,10 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
betSummary={prediction.bet_summary || []}
marketLabels={marketLabels}
title={t("market-board")}
info={uiText("market-board-info", "Modelin her markette gordugu olasilik dagilimi.")}
info={uiText(
"market-board-info",
"Modelin her markette gordugu olasilik dagilimi.",
)}
/>
{prediction.v27_engine ? (
@@ -1130,16 +1348,37 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
title={t("bet-advice")}
info={uiText("bet-advice-info", "Modelin nihai aksiyon onerisi.")}
/>
<HStack justify="space-between" align={{ base: "start", md: "center" }} flexDir={{ base: "column", md: "row" }} gap={3}>
<HStack
justify="space-between"
align={{ base: "start", md: "center" }}
flexDir={{ base: "column", md: "row" }}
gap={3}
>
<HStack gap={3}>
<Badge colorPalette={prediction.bet_advice.playable ? "green" : "red"} variant="solid" borderRadius="full" fontSize="sm" px={3} py={1}>
<Badge
colorPalette={prediction.bet_advice.playable ? "green" : "red"}
variant="solid"
borderRadius="full"
fontSize="sm"
px={3}
py={1}
>
{prediction.bet_advice.playable ? "OYNA" : "OYNAMA"}
</Badge>
<Badge colorPalette={mainBandPalette} variant="subtle" borderRadius="full" fontSize="sm" px={3} py={1}>
<Badge
colorPalette={mainBandPalette}
variant="subtle"
borderRadius="full"
fontSize="sm"
px={3}
py={1}
>
{getConfidenceBandLabel(prediction.bet_advice.confidence_band)}
</Badge>
<Badge
colorPalette={getSignalTierPalette(prediction.bet_advice.signal_tier)}
colorPalette={getSignalTierPalette(
prediction.bet_advice.signal_tier,
)}
variant="subtle"
borderRadius="full"
fontSize="sm"
@@ -1148,15 +1387,25 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
>
{getSignalTierLabel(prediction.bet_advice.signal_tier)}
</Badge>
<Text color="fg.muted">{resolveReason(prediction.bet_advice.reason)}</Text>
<Text color="fg.muted">
{resolveReason(prediction.bet_advice.reason)}
</Text>
</HStack>
<Badge variant="surface" fontSize="sm" px={3} py={1}>
{uiText("recommended-stake-inline", "Onerilen miktar")}: {formatUnits(prediction.bet_advice.suggested_stake_units)}
{uiText("recommended-stake-inline", "Onerilen miktar")}:{" "}
{formatUnits(prediction.bet_advice.suggested_stake_units)}
</Badge>
</HStack>
<Separator />
<SectionTitle icon={LuBrain} title={t("reasoning")} info="Modelin bu maci neden bu sekilde okudugunun ust seviye ozeti." />
<ReasonList items={prediction.reasoning_factors} resolveReason={resolveReason} />
<SectionTitle
icon={LuBrain}
title={t("reasoning")}
info="Modelin bu maci neden bu sekilde okudugunun ust seviye ozeti."
/>
<ReasonList
items={prediction.reasoning_factors}
resolveReason={resolveReason}
/>
</Card.Body>
</Card.Root>
</VStack>
+44 -15
View File
@@ -153,7 +153,12 @@ function TripleValueCard({
isValue ? "green.300" : "gray.200",
isValue ? "green.700" : "gray.700",
);
const edgeColor = entry.edge > 0.03 ? "green.500" : entry.edge < -0.03 ? "red.400" : "fg.muted";
const edgeColor =
entry.edge > 0.03
? "green.500"
: entry.edge < -0.03
? "red.400"
: "fg.muted";
return (
<Box
@@ -249,13 +254,7 @@ function ProgressBar({
const trackBg = useColorModeValue("gray.100", "gray.700");
const w = max > 0 ? Math.min(100, (value / max) * 100) : 0;
return (
<Box
h="10px"
w="full"
bg={trackBg}
borderRadius="full"
overflow="hidden"
>
<Box h="10px" w="full" bg={trackBg} borderRadius="full" overflow="hidden">
<Box
h="full"
w={`${w}%`}
@@ -460,13 +459,28 @@ function HtftGrid({
{/* Column headers */}
<Grid templateColumns="50px repeat(3, 1fr)" gap={1.5} mb={1.5}>
<Box />
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
<Text
fontSize="2xs"
fontWeight="bold"
textAlign="center"
color="fg.muted"
>
MS 1
</Text>
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
<Text
fontSize="2xs"
fontWeight="bold"
textAlign="center"
color="fg.muted"
>
MS X
</Text>
<Text fontSize="2xs" fontWeight="bold" textAlign="center" color="fg.muted">
<Text
fontSize="2xs"
fontWeight="bold"
textAlign="center"
color="fg.muted"
>
MS 2
</Text>
</Grid>
@@ -611,7 +625,12 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
{/* Engine version badge */}
<HStack>
<Badge colorPalette="purple" variant="subtle" borderRadius="full" fontSize="2xs">
<Badge
colorPalette="purple"
variant="subtle"
borderRadius="full"
fontSize="2xs"
>
{engine.version}
</Badge>
{engine.consensus && (
@@ -621,11 +640,18 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
borderRadius="full"
fontSize="2xs"
>
{engine.consensus === "AGREE" ? "Motorlar Uyumlu" : "Motorlar Farklı"}
{engine.consensus === "AGREE"
? "Motorlar Uyumlu"
: "Motorlar Farklı"}
</Badge>
)}
{valueHits.length > 0 && (
<Badge colorPalette="green" variant="outline" borderRadius="full" fontSize="2xs">
<Badge
colorPalette="green"
variant="outline"
borderRadius="full"
fontSize="2xs"
>
{valueHits.length} Değer Sinyali
</Badge>
)}
@@ -656,7 +682,10 @@ export default function V28OddsBandPanel({ engine }: V28OddsBandPanelProps) {
{/* Cards + HTFT side by side on large screens */}
{(hasCards || hasHtft) && (
<Grid
templateColumns={{ base: "1fr", xl: hasCards && hasHtft ? "1fr 1fr" : "1fr" }}
templateColumns={{
base: "1fr",
xl: hasCards && hasHtft ? "1fr 1fr" : "1fr",
}}
gap={4}
>
{hasCards && <CardsSection cards={cards} />}
@@ -154,8 +154,9 @@ export default function PredictionsContent() {
</Badge>
<Badge
colorPalette={
riskColors[pred.risk?.level?.toUpperCase()] ||
"gray"
riskColors[
pred.risk?.level?.toUpperCase()
] || "gray"
}
variant="subtle"
fontSize="2xs"
+325
View File
@@ -0,0 +1,325 @@
"use client";
import {
Box,
Flex,
Heading,
Text,
VStack,
HStack,
SimpleGrid,
Spinner,
Card,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { useSubscriptionPlans } from "@/lib/api/subscriptions/use-hooks";
import { usePaddleCheckout } from "@/lib/paddle";
import { PricingCard } from "@/components/subscription";
import { LoginModal } from "@/components/auth/login-modal";
import { SlideUp } from "@/components/motion";
import { SegmentedControl } from "@/components/ui/forms/segmented-control";
import type {
PlanInfo,
PlanType,
BillingInterval,
} from "@/lib/api/subscriptions/types";
import { LuMessageCircleQuestion, LuChevronDown } from "react-icons/lu";
/** Static fallback plans used when the backend is not reachable */
const FALLBACK_PLANS: readonly PlanInfo[] = [
{
id: "free" as PlanType,
name: "Free",
description: "",
monthlyPrice: 0,
yearlyPrice: 0,
currency: "TRY",
features: [],
limits: { maxAnalyses: 3, maxCoupons: 1 },
highlighted: false,
},
{
id: "plus" as PlanType,
name: "Plus",
description: "",
monthlyPrice: 149,
yearlyPrice: 1490,
currency: "TRY",
features: [],
limits: { maxAnalyses: 15, maxCoupons: 5 },
highlighted: true,
},
{
id: "premium" as PlanType,
name: "Premium",
description: "",
monthlyPrice: 349,
yearlyPrice: 3490,
currency: "TRY",
features: [],
limits: { maxAnalyses: 999, maxCoupons: 999 },
highlighted: false,
},
] as const;
/**
* Main pricing page content.
* Fetches plans from API, shows monthly/yearly toggle,
* renders pricing cards and FAQ section.
*/
export default function PricingContent() {
const t = useTranslations("pricing");
const { data: session } = useSession();
const { data: plansData, isLoading } = useSubscriptionPlans();
const { startCheckout, isLoading: checkoutLoading } = usePaddleCheckout();
const [isYearly, setIsYearly] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false);
const [selectedCheckoutPlan, setSelectedCheckoutPlan] =
useState<PlanType | null>(null);
const subtitleColor = useColorModeValue("gray.600", "gray.400");
const currentPlan = session?.user?.subscriptionPlan ?? "free";
const plans = plansData?.data ?? FALLBACK_PLANS;
// Enrich plans with i18n translated names/descriptions/features
const enrichedPlans: readonly PlanInfo[] = plans.map((plan) => {
const planKey = String(plan.id).toLowerCase() as
| "free"
| "plus"
| "premium";
const featureKeys = getFeatureKeysForPlan(planKey, plan.limits);
return {
...plan,
name: t(`plan.${planKey}.name`),
description: t(`plan.${planKey}.description`),
features: featureKeys.map((key) => {
const match = key.match(/^(\d+)\s+(.+)$/);
if (match) {
const count = match[1];
const transKey = match[2];
return `${count} ${t(`feature.${transKey}`)}`;
}
if (key.startsWith("unlimited-")) {
const transKey = key.replace("unlimited-", "");
return `${t("feature.unlimited")} ${t(`feature.${transKey}`)}`;
}
return t(`feature.${key}`);
}),
};
});
const handlePlanSelect = (plan: PlanInfo) => {
if (plan.id === "free") return;
if (!session) {
setLoginModalOpen(true);
return;
}
setSelectedCheckoutPlan(plan.id);
const interval: BillingInterval = isYearly
? ("yearly" as BillingInterval)
: ("monthly" as BillingInterval);
startCheckout(plan.id, interval);
};
const faqItems = [
{ q: t("faq.q1"), a: t("faq.a1") },
{ q: t("faq.q2"), a: t("faq.a2") },
{ q: t("faq.q3"), a: t("faq.a3") },
{ q: t("faq.q4"), a: t("faq.a4") },
];
return (
<>
<SlideUp>
<Box py={{ base: 8, md: 16 }}>
{/* Hero Section */}
<VStack gap={4} textAlign="center" mb={{ base: 10, md: 14 }}>
<Heading
as="h1"
fontSize={{ base: "3xl", md: "4xl", lg: "5xl" }}
fontWeight="900"
letterSpacing="-0.03em"
lineHeight="1.1"
>
{t("title")}
</Heading>
<Text
fontSize={{ base: "md", md: "lg" }}
color={subtitleColor}
maxW="xl"
>
{t("subtitle")}
</Text>
{/* Monthly / Yearly Toggle */}
<HStack
gap={3}
bg={useColorModeValue("gray.50", "gray.900")}
p={1.5}
borderRadius="xl"
mt={2}
>
<SegmentedControl
value={isYearly ? "yearly" : "monthly"}
onValueChange={(details) =>
setIsYearly(details.value === "yearly")
}
items={[
{ label: t("monthly"), value: "monthly" },
{
label: `${t("yearly")} 🎁`,
value: "yearly",
},
]}
size="sm"
/>
</HStack>
{isYearly && (
<Text fontSize="sm" color="primary.500" fontWeight="semibold">
{t("yearly-save")}
</Text>
)}
</VStack>
{/* Pricing Cards */}
{isLoading ? (
<Flex justify="center" py={20}>
<Spinner size="lg" color="primary.500" />
</Flex>
) : (
<SimpleGrid
columns={{ base: 1, md: 3 }}
gap={{ base: 6, md: 8 }}
maxW="5xl"
mx="auto"
>
{enrichedPlans.map((plan) => (
<PricingCard
key={plan.id}
plan={plan}
isCurrentPlan={currentPlan === plan.id}
isYearly={isYearly}
onSelect={handlePlanSelect}
isLoading={
checkoutLoading && selectedCheckoutPlan === plan.id
}
/>
))}
</SimpleGrid>
)}
{/* FAQ Section */}
<Box maxW="3xl" mx="auto" mt={{ base: 16, md: 24 }}>
<VStack gap={2} textAlign="center" mb={8}>
<HStack gap={2} color="primary.500">
<LuMessageCircleQuestion size={24} />
<Heading as="h2" size="lg" fontWeight="bold">
{t("faq-title")}
</Heading>
</HStack>
</VStack>
<VStack gap={4} align="stretch">
{faqItems.map((item, idx) => (
<FaqItem key={idx} question={item.q} answer={item.a} />
))}
</VStack>
</Box>
</Box>
</SlideUp>
<LoginModal
open={loginModalOpen}
onOpenChange={setLoginModalOpen}
initialMode="login"
/>
</>
);
}
// ────────────────────────────────────────────
// Internal helpers
// ────────────────────────────────────────────
function getFeatureKeysForPlan(
planKey: "free" | "plus" | "premium",
limits: { maxAnalyses: number; maxCoupons: number },
): string[] {
const analysisLabel =
limits.maxAnalyses >= 999
? "unlimited-daily-analyses"
: `${limits.maxAnalyses} daily-analyses`;
const couponLabel =
limits.maxCoupons >= 999
? "unlimited-daily-coupons"
: `${limits.maxCoupons} daily-coupons`;
const base = [analysisLabel, couponLabel, "basic-analysis"];
if (planKey === "plus") {
return [...base, "detailed-analysis", "h2h-comparison", "coupon-builder"];
}
if (planKey === "premium") {
return [
...base,
"detailed-analysis",
"h2h-comparison",
"coupon-builder",
"spor-toto",
"ad-free",
"priority-support",
];
}
return base;
}
/** Collapsible FAQ item */
function FaqItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
const cardBg = useColorModeValue("white", "gray.900");
return (
<Card.Root
bg={cardBg}
borderRadius="xl"
cursor="pointer"
transition="all 0.2s"
_hover={{ shadow: "md" }}
onClick={() => setOpen((prev) => !prev)}
>
<Card.Body p={5}>
<Flex justify="space-between" align="center">
<Text fontWeight="semibold" fontSize="sm">
{question}
</Text>
<Box
transform={open ? "rotate(180deg)" : "rotate(0deg)"}
transition="transform 0.2s"
flexShrink={0}
ml={3}
>
<LuChevronDown size={18} />
</Box>
</Flex>
{open && (
<Text mt={3} fontSize="sm" color="fg.muted" lineHeight="1.7">
{answer}
</Text>
)}
</Card.Body>
</Card.Root>
);
}
@@ -42,6 +42,7 @@ import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { useRouter } from "next/navigation";
import { SubscriptionCard } from "@/components/subscription";
interface InfoRowProps {
icon: React.ReactNode;
@@ -174,6 +175,9 @@ export default function ProfileContent() {
</Card.Body>
</Card.Root>
{/* Subscription Info */}
<SubscriptionCard />
{/* Account Info */}
<Card.Root
bg={cardBg}
+4 -4
View File
@@ -399,7 +399,7 @@ function HomeCard() {
const imagesCollection = createListCollection({ items: images });
const currentImage = imagesCollection.items.find(
(img) => img.value === selectedImage
(img) => img.value === selectedImage,
);
const nodeCollection = createTreeCollection<Node>({
@@ -410,7 +410,7 @@ function HomeCard() {
const [tabs, setTabs] = React.useState<Item[]>(itemsTabs);
const [selectedTab, setSelectedTab] = React.useState<string | null>(
itemsTabs[0].id
itemsTabs[0].id,
);
const uuid = () => {
@@ -2682,7 +2682,7 @@ function HomeCard() {
}
onCheckedChange={(changes) => {
setSelection(
changes.checked ? items.map((item) => item.name) : []
changes.checked ? items.map((item) => item.name) : [],
);
}}
/>
@@ -2710,7 +2710,7 @@ function HomeCard() {
setSelection((prev) =>
changes.checked
? [...prev, item.name]
: selection.filter((name) => name !== item.name)
: selection.filter((name) => name !== item.name),
);
}}
/>
+26 -27
View File
@@ -22,6 +22,8 @@ import {
useSyncBulletin,
useRolloverHistory,
} from "@/lib/api/spor-toto/use-hooks";
import { useQueryClient } from "@tanstack/react-query";
import { UsersQueryKeys } from "@/lib/api/users/use-hooks";
import type {
SporTotoBulletinDto,
SporTotoPredictionResultDto,
@@ -59,15 +61,11 @@ export default function SporTotoContent() {
const rolloverHistory = useRolloverHistory(10);
const syncBulletin = useSyncBulletin();
const generatePrediction = useGeneratePrediction();
const queryClient = useQueryClient();
const toast = (opts: { title: string; status: string }) =>
toaster.create({
title: opts.title,
type: opts.status as
| "success"
| "warning"
| "error"
| "info"
| "loading",
type: opts.status as "success" | "warning" | "error" | "info" | "loading",
});
const handleSync = async () => {
@@ -91,6 +89,7 @@ export default function SporTotoContent() {
bulletinId: selectedBulletin,
strategy,
});
queryClient.invalidateQueries({ queryKey: UsersQueryKeys.me() });
toast({
title: t("prediction-generated"),
status: "success",
@@ -102,27 +101,27 @@ export default function SporTotoContent() {
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"),
},
];
{
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>
+3
View File
@@ -0,0 +1,3 @@
export { PlanBadge } from "./plan-badge";
export { PricingCard } from "./pricing-card";
export { SubscriptionCard } from "./subscription-card";
@@ -0,0 +1,39 @@
"use client";
import { Badge } from "@chakra-ui/react";
import type { PlanType } from "@/lib/api/subscriptions/types";
interface PlanBadgeProps {
plan: PlanType | "free" | "plus" | "premium";
size?: "sm" | "md" | "lg";
}
const planColorMap: Record<string, string> = {
free: "gray",
plus: "blue",
premium: "yellow",
};
/**
* A colored badge showing the user's subscription plan.
* Uses Chakra v3 Badge component with colorPalette.
*/
export function PlanBadge({ plan, size = "sm" }: PlanBadgeProps) {
const planKey = String(plan).toLowerCase();
const colorPalette = planColorMap[planKey] ?? "gray";
const label = planKey.charAt(0).toUpperCase() + planKey.slice(1);
return (
<Badge
colorPalette={colorPalette}
variant="solid"
size={size}
borderRadius="full"
px={2}
fontWeight="bold"
letterSpacing="0.02em"
>
{label}
</Badge>
);
}
@@ -0,0 +1,192 @@
"use client";
import {
Box,
Card,
Heading,
Text,
VStack,
HStack,
Button,
Badge,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import type { PlanInfo } from "@/lib/api/subscriptions/types";
import { LuCheck, LuZap, LuCrown, LuRocket } from "react-icons/lu";
interface PricingCardProps {
plan: PlanInfo;
isCurrentPlan: boolean;
isYearly: boolean;
onSelect: (plan: PlanInfo) => void;
isLoading: boolean;
}
const planIconMap: Record<string, React.ReactNode> = {
free: <LuZap size={20} />,
plus: <LuRocket size={20} />,
premium: <LuCrown size={20} />,
};
/**
* Individual pricing card for a subscription plan.
* Shows plan details, features, price, and CTA button.
*/
export function PricingCard({
plan,
isCurrentPlan,
isYearly,
onSelect,
isLoading,
}: PricingCardProps) {
const t = useTranslations("pricing");
const highlightBorder = useColorModeValue("primary.500", "primary.400");
const cardBg = useColorModeValue("white", "gray.900");
const mutedText = useColorModeValue("gray.500", "gray.400");
const price = isYearly ? plan.yearlyPrice : plan.monthlyPrice;
const displayPrice = isYearly
? Math.round(plan.yearlyPrice / 12)
: plan.monthlyPrice;
const isFree = plan.id === "free";
const buttonLabel = isCurrentPlan
? t("current-plan")
: isFree
? t("get-started")
: t("upgrade");
return (
<Card.Root
bg={cardBg}
borderWidth={plan.highlighted ? "2px" : "1px"}
borderColor={plan.highlighted ? highlightBorder : "border.muted"}
borderRadius="2xl"
position="relative"
overflow="visible"
transition="all 0.3s ease"
_hover={{
transform: "translateY(-4px)",
shadow: plan.highlighted ? "2xl" : "lg",
}}
height="full"
>
{/* Most Popular Badge */}
{plan.highlighted && (
<Badge
position="absolute"
top="-3"
left="50%"
transform="translateX(-50%)"
colorPalette="primary"
variant="solid"
borderRadius="full"
px={4}
py={1}
fontSize="xs"
fontWeight="bold"
whiteSpace="nowrap"
>
{t("most-popular")}
</Badge>
)}
<Card.Body p={6}>
<VStack gap={6} align="stretch" height="full">
{/* Plan Header */}
<VStack gap={2} align="start">
<HStack gap={2}>
<Box
p={2}
borderRadius="lg"
bg={plan.highlighted ? "primary.50" : "gray.50"}
_dark={{
bg: plan.highlighted ? "primary.950" : "gray.800",
}}
color={plan.highlighted ? "primary.500" : "fg.muted"}
>
{planIconMap[plan.id] ?? <LuZap size={20} />}
</Box>
<Heading as="h3" size="lg" fontWeight="bold">
{plan.name}
</Heading>
</HStack>
<Text fontSize="sm" color={mutedText} minH="10">
{plan.description}
</Text>
</VStack>
{/* Price */}
<Box>
{isFree ? (
<VStack gap={0} align="start">
<HStack gap={1} align="baseline">
<Text fontSize="4xl" fontWeight="900" lineHeight="1">
{plan.currency === "TRY" ? "₺" : "$"}0
</Text>
</HStack>
<Text fontSize="xs" color={mutedText}>
{t("free-forever")}
</Text>
</VStack>
) : (
<VStack gap={0} align="start">
<HStack gap={1} align="baseline">
<Text fontSize="4xl" fontWeight="900" lineHeight="1">
{plan.currency === "TRY" ? "₺" : "$"}
{displayPrice}
</Text>
<Text fontSize="sm" color={mutedText}>
{t("per-month")}
</Text>
</HStack>
{isYearly && (
<Text fontSize="xs" color={mutedText}>
{plan.currency === "TRY" ? "₺" : "$"}
{price}
{t("per-year")} · {t("billed-yearly")}
</Text>
)}
</VStack>
)}
</Box>
{/* Features List */}
<VStack gap={3} align="start" flex={1}>
{plan.features.map((feature) => (
<HStack key={feature} gap={2} align="start">
<Box
color={plan.highlighted ? "primary.500" : "green.500"}
mt="0.5"
flexShrink={0}
>
<LuCheck size={16} />
</Box>
<Text fontSize="sm">{feature}</Text>
</HStack>
))}
</VStack>
{/* CTA */}
<Button
w="full"
size="lg"
borderRadius="xl"
fontWeight="bold"
variant={
isCurrentPlan ? "outline" : plan.highlighted ? "solid" : "outline"
}
colorPalette={plan.highlighted ? "primary" : "gray"}
disabled={isCurrentPlan}
loading={isLoading}
onClick={() => onSelect(plan)}
>
{buttonLabel}
</Button>
</VStack>
</Card.Body>
</Card.Root>
);
}
@@ -0,0 +1,220 @@
"use client";
import {
Box,
VStack,
Heading,
Text,
Flex,
Card,
Button,
Spinner,
HStack,
Textarea,
} from "@chakra-ui/react";
import { useTranslations } from "next-intl";
import { useColorModeValue } from "@/components/ui/color-mode";
import {
useMySubscription,
useCancelSubscription,
} from "@/lib/api/subscriptions/use-hooks";
import { PlanBadge } from "./plan-badge";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { LuCalendar, LuTriangleAlert, LuX, LuCheck } from "react-icons/lu";
import { useRouter } from "@/i18n/navigation";
/**
* Subscription info card for the Profile page.
* Shows current plan, billing dates, cancel option.
*/
export function SubscriptionCard() {
const t = useTranslations("subscription");
const tCommon = useTranslations("common");
const { data: session } = useSession();
const { data: subData, isLoading } = useMySubscription(!!session);
const router = useRouter();
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const [showCancelForm, setShowCancelForm] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const cancelMutation = useCancelSubscription();
const subscription = subData?.data ?? null;
const plan = subscription?.plan ?? session?.user?.subscriptionPlan ?? "free";
const isFree = plan === "free";
const handleCancel = async () => {
await cancelMutation.mutateAsync({ reason: cancelReason || undefined });
setShowCancelForm(false);
setCancelReason("");
};
if (isLoading) {
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<Flex justify="center" py={8}>
<Spinner size="sm" color="primary.500" />
</Flex>
</Card.Body>
</Card.Root>
);
}
return (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Header>
<Flex justify="space-between" align="center">
<Heading as="h3" size="sm">
{t("title")}
</Heading>
<PlanBadge plan={plan} />
</Flex>
</Card.Header>
<Card.Body pt={0}>
<VStack gap={4} align="stretch">
{/* Subscription Details */}
{subscription && !isFree && (
<>
{subscription.currentPeriodEnd && (
<Flex justify="space-between" align="center">
<HStack gap={2} color="fg.muted">
<LuCalendar size={14} />
<Text fontSize="sm">{t("next-billing")}</Text>
</HStack>
<Text fontSize="sm" fontWeight="semibold">
{new Date(
subscription.currentPeriodEnd,
).toLocaleDateString()}
</Text>
</Flex>
)}
{subscription.cancelEffectiveDate && (
<Box
p={3}
bg="orange.50"
_dark={{ bg: "orange.950" }}
borderRadius="lg"
>
<HStack gap={2}>
<LuTriangleAlert size={14} color="orange" />
<Text
fontSize="sm"
color="orange.600"
_dark={{ color: "orange.300" }}
>
{t("cancelled-info", {
date: new Date(
subscription.cancelEffectiveDate,
).toLocaleDateString(),
})}
</Text>
</HStack>
</Box>
)}
</>
)}
{/* Actions */}
<Flex gap={2} direction={{ base: "column", sm: "row" }}>
{isFree ? (
<Button
onClick={() => router.push("/pricing")}
colorPalette="primary"
variant="solid"
size="sm"
borderRadius="lg"
flex={1}
>
{t("upgrade-cta")}
</Button>
) : (
<>
<Button
onClick={() => router.push("/pricing")}
colorPalette="primary"
variant="outline"
size="sm"
borderRadius="lg"
flex={1}
>
{t("manage")}
</Button>
{!subscription?.cancelEffectiveDate && (
<Button
variant="ghost"
size="sm"
colorPalette="red"
onClick={() => setShowCancelForm(true)}
>
{t("cancel")}
</Button>
)}
</>
)}
</Flex>
{/* Cancel Confirmation */}
{showCancelForm && (
<Box
p={4}
borderWidth="1px"
borderColor="red.200"
_dark={{ borderColor: "red.800" }}
borderRadius="lg"
>
<VStack gap={3} align="stretch">
<Text
fontSize="sm"
fontWeight="semibold"
color="red.600"
_dark={{ color: "red.300" }}
>
{t("cancel-confirm-title")}
</Text>
<Text fontSize="xs" color="fg.muted">
{t("cancel-confirm-message")}
</Text>
<Textarea
size="sm"
placeholder={t("cancel-reason-placeholder")}
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
rows={2}
/>
<HStack gap={2} justify="flex-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setShowCancelForm(false);
setCancelReason("");
}}
>
<LuX />
{tCommon("cancel")}
</Button>
<Button
colorPalette="red"
variant="solid"
size="sm"
loading={cancelMutation.isPending}
onClick={handleCancel}
>
<LuCheck />
{t("cancel")}
</Button>
</HStack>
</VStack>
</Box>
)}
</VStack>
</Card.Body>
</Card.Root>
);
}
+139 -30
View File
@@ -19,7 +19,12 @@ import { useSession } from "next-auth/react";
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, LuChevronDown } from "react-icons/lu";
import {
LuArrowLeft,
LuCalendar,
LuTrophy,
LuChevronDown,
} from "react-icons/lu";
import type { MatchResponseDto } from "@/lib/api/matches/types";
import { useState, useMemo, useCallback } from "react";
import { LoginModal } from "@/components/auth/login-modal";
@@ -29,7 +34,8 @@ import { LoginModal } from "@/components/auth/login-modal";
// ─────────────────────────────────────────────────
function getMatchTimestamp(match: MatchResponseDto): number {
const raw = typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
const raw =
typeof match.mstUtc === "string" ? Number(match.mstUtc) : match.mstUtc;
return Number.isFinite(raw) ? raw : 0;
}
@@ -39,19 +45,32 @@ function getMatchStatus(match: MatchResponseDto): string {
function isMatchFinished(match: MatchResponseDto): boolean {
const status = getMatchStatus(match);
return status === "FT" || status === "FINISHED" || status === "POSTGAME" || status === "POST_GAME";
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";
return (
status === "LIVE" || status === "INPROGRESS" || status === "IN_PROGRESS"
);
}
function getTeamSideName(team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"], fallback?: unknown): string {
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 {
function getTeamSideLogo(
team: MatchResponseDto["homeTeam"] | MatchResponseDto["awayTeam"],
fallback?: unknown,
): string {
return String(team?.logo || fallback || "");
}
@@ -74,7 +93,10 @@ const SEASONS = (() => {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const startYear = currentMonth >= 8 ? currentYear : currentYear - 1;
return Array.from({ length: 5 }, (_, i) => `${startYear - i}-${startYear - i + 1}`);
return Array.from(
{ length: 5 },
(_, i) => `${startYear - i}-${startYear - i + 1}`,
);
})();
// ─────────────────────────────────────────────────
@@ -97,7 +119,11 @@ export default function TeamDetailContent() {
data: matchesResponse,
isLoading: matchesLoading,
isFetching: matchesFetching,
} = useTeamMatches(teamId, { page: currentPage, limit: 20, season: activeSeason });
} = useTeamMatches(teamId, {
page: currentPage,
limit: 20,
season: activeSeason,
});
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
@@ -109,20 +135,30 @@ export default function TeamDetailContent() {
const team = teamWrapper?.data as Record<string, unknown> | undefined;
// matchesResponse = { success, status, message, data: { data: [...], total, page, limit, totalPages } }
const paginationWrapper = matchesResponse as Record<string, unknown> | undefined;
const paginationData = paginationWrapper?.data as Record<string, unknown> | undefined;
const matches: MatchResponseDto[] = (Array.isArray(paginationData?.data) ? paginationData.data : paginationData?.data ? [] : []) as MatchResponseDto[];
const paginationWrapper = matchesResponse as
| Record<string, unknown>
| undefined;
const paginationData = paginationWrapper?.data as
| Record<string, unknown>
| undefined;
const matches: MatchResponseDto[] = (
Array.isArray(paginationData?.data)
? paginationData.data
: paginationData?.data
? []
: []
) as MatchResponseDto[];
const totalPages = (paginationData?.totalPages as number) ?? 1;
const totalMatches = (paginationData?.total as number) ?? 0;
// Separate past and upcoming matches
const pastMatches = useMemo(
() => matches.filter((m) => isMatchFinished(m)),
[matches]
[matches],
);
const upcomingMatches = useMemo(
() => matches.filter((m) => !isMatchFinished(m)),
[matches]
[matches],
);
// Pagination handlers
@@ -169,7 +205,9 @@ export default function TeamDetailContent() {
if (!team) {
return (
<Flex justify="center" py={20} direction="column" align="center" gap={4}>
<Text color="fg.muted" fontSize="lg">Takım bulunamadı</Text>
<Text color="fg.muted" fontSize="lg">
Takım bulunamadı
</Text>
<Button variant="outline" onClick={() => router.back()}>
<LuArrowLeft /> Geri
</Button>
@@ -181,13 +219,24 @@ export default function TeamDetailContent() {
<SlideUp>
<Box>
{/* Back Button */}
<Button variant="ghost" size="sm" mb={4} onClick={() => router.back()} gap={1.5}>
<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.Root
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
mb={6}
>
<Card.Body>
<HStack gap={6} justify="center" align="center">
{(team as Record<string, unknown>).logo ? (
@@ -206,7 +255,9 @@ export default function TeamDetailContent() {
justify="center"
>
<Text fontSize="3xl" fontWeight="bold" color="primary.fg">
{String((team as Record<string, unknown>).name || "T").charAt(0)}
{String(
(team as Record<string, unknown>).name || "T",
).charAt(0)}
</Text>
</Flex>
)}
@@ -249,7 +300,11 @@ export default function TeamDetailContent() {
cardBg={cardBg}
borderColor={borderColor}
statusBadge={getStatusBadge(match)}
onClick={() => session ? router.push(`/matches/${match.id}`) : setLoginModalOpen(true)}
onClick={() =>
session
? router.push(`/matches/${match.id}`)
: setLoginModalOpen(true)
}
/>
))}
</VStack>
@@ -260,7 +315,13 @@ export default function TeamDetailContent() {
{/* Past Matches — Season Grouped */}
<FadeIn>
<Box>
<Flex align="center" justify="space-between" mb={4} flexWrap="wrap" gap={2}>
<Flex
align="center"
justify="space-between"
mb={4}
flexWrap="wrap"
gap={2}
>
<Heading as="h2" size="lg">
📊 Geçmiş Maçlar
</Heading>
@@ -320,7 +381,11 @@ export default function TeamDetailContent() {
cardBg={cardBg}
borderColor={borderColor}
statusBadge={getStatusBadge(match)}
onClick={() => session ? router.push(`/matches/${match.id}`) : setLoginModalOpen(true)}
onClick={() =>
session
? router.push(`/matches/${match.id}`)
: setLoginModalOpen(true)
}
/>
))}
</VStack>
@@ -357,7 +422,9 @@ export default function TeamDetailContent() {
key={pageNum}
size="sm"
variant={pageNum === currentPage ? "solid" : "ghost"}
bg={pageNum === currentPage ? seasonActiveBg : undefined}
bg={
pageNum === currentPage ? seasonActiveBg : undefined
}
color={pageNum === currentPage ? "white" : undefined}
borderRadius="full"
minW="36px"
@@ -407,7 +474,13 @@ interface MatchRowProps {
onClick: () => void;
}
function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRowProps) {
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);
@@ -436,17 +509,34 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
{homeTeamName}
</Text>
{homeTeamLogo ? (
<Image src={homeTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
<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
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 ? (
{hasScore &&
match.scoreHome !== undefined &&
match.scoreHome !== null ? (
<Text fontSize="md" fontWeight="900">
{match.scoreHome} - {match.scoreAway}
</Text>
@@ -468,10 +558,25 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
{/* Away Team */}
<HStack gap={2} flex={1}>
{awayTeamLogo ? (
<Image src={awayTeamLogo} alt="" boxSize="24px" objectFit="contain" flexShrink={0} />
<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
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>
@@ -483,7 +588,11 @@ function MatchRow({ match, cardBg, borderColor, statusBadge, onClick }: MatchRow
{/* Status + League */}
<HStack gap={2} flexShrink={0} ml={3}>
{leagueLabel && (
<Text fontSize="2xs" color="fg.muted" display={{ base: "none", md: "block" }}>
<Text
fontSize="2xs"
color="fg.muted"
display={{ base: "none", md: "block" }}
>
{leagueLabel}
</Text>
)}
+12 -2
View File
@@ -69,7 +69,13 @@ export default function TeamsContent() {
<Spinner size="lg" color="primary.500" />
</Flex>
) : query.length < 2 ? (
<Flex justify="center" py={16} direction="column" align="center" gap={3}>
<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
@@ -117,7 +123,11 @@ export default function TeamsContent() {
align="center"
justify="center"
>
<Text fontSize="xl" fontWeight="bold" color="primary.fg">
<Text
fontSize="xl"
fontWeight="bold"
color="primary.fg"
>
{team.name?.charAt(0) || "T"}
</Text>
</Flex>
+18 -18
View File
@@ -1,8 +1,8 @@
'use client';
"use client";
import { useEffect, useState } from 'react';
import { Icon, IconButton, Presence } from '@chakra-ui/react';
import { FiChevronUp } from 'react-icons/fi';
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);
@@ -12,14 +12,14 @@ const BackToTop = () => {
setIsVisible(window.pageYOffset > 300);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
behavior: "smooth",
});
};
@@ -27,19 +27,19 @@ const BackToTop = () => {
<Presence
unmountOnExit
present={isVisible}
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
animationDuration='moderate'
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'
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>
+32 -25
View File
@@ -1,6 +1,11 @@
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';
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;
@@ -9,25 +14,27 @@ interface ButtonLoadingProps {
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>
);
});
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>
);
},
);
+9 -6
View File
@@ -1,13 +1,16 @@
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';
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) {
export const CloseButton = React.forwardRef<
HTMLButtonElement,
CloseButtonProps
>(function CloseButton(props, ref) {
return (
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
);
+9 -6
View File
@@ -1,11 +1,14 @@
'use client';
"use client";
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
import { createRecipeContext } from '@chakra-ui/react';
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react";
import { createRecipeContext } from "@chakra-ui/react";
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
export interface LinkButtonProps extends HTMLChakraProps<
"a",
RecipeProps<"button">
> {}
const { withContext } = createRecipeContext({ key: 'button' });
const { withContext } = createRecipeContext({ key: "button" });
// Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a");
+43 -30
View File
@@ -1,44 +1,57 @@
'use client';
"use client";
import type { ButtonProps } from '@chakra-ui/react';
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
import * as React from 'react';
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'];
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' },
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];
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} />;
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;
+84 -66
View File
@@ -1,89 +1,107 @@
'use client';
"use client";
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
import { CloseButton } from '@/components/ui/buttons/close-button';
import * as React from 'react';
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>
);
},
);
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>
);
},
);
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 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 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;
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 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;
+17 -10
View File
@@ -1,19 +1,26 @@
'use client';
"use client";
import { Listbox as ChakraListbox } from '@chakra-ui/react';
import * as React from 'react';
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) {
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 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) {
export const ListboxItem = React.forwardRef<
HTMLDivElement,
ChakraListbox.ItemProps
>(function ListboxItem(props, ref) {
const { children, ...rest } = props;
return (
<ChakraListbox.Item {...rest} ref={ref}>
+90 -63
View File
@@ -1,45 +1,56 @@
'use client';
"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';
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>
);
},
);
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>
);
},
);
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) {
export const SelectContent = React.forwardRef<
HTMLDivElement,
SelectContentProps
>(function SelectContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
@@ -50,7 +61,10 @@ export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps
);
});
export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProps>(function SelectItem(props, ref) {
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}>
@@ -60,32 +74,44 @@ export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProp
);
});
interface SelectValueTextProps extends Omit<ChakraSelect.ValueTextProps, 'children'> {
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) {
export const SelectValueText = React.forwardRef<
HTMLSpanElement,
SelectValueTextProps
>(function SelectValueText(props, ref) {
const { children, ...rest } = props;
return (
<ChakraSelect.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }}>
<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
) : (
@@ -102,17 +128,18 @@ 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 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;
+33 -27
View File
@@ -1,38 +1,44 @@
'use client';
"use client";
import { TreeView as ChakraTreeView } from '@chakra-ui/react';
import * as React from 'react';
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} />;
},
);
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 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 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 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 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;
+68 -61
View File
@@ -1,19 +1,21 @@
'use client';
"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';
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} />;
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
);
}
export type ColorMode = 'light' | 'dark';
export type ColorMode = "light" | "dark";
export interface UseColorModeReturn {
colorMode: ColorMode;
@@ -25,7 +27,7 @@ export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
const colorMode = forcedTheme || resolvedTheme;
const toggleColorMode = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
setTheme(resolvedTheme === "dark" ? "light" : "dark");
};
return {
colorMode: colorMode as ColorMode,
@@ -43,66 +45,71 @@ export function useColorModeValue<T>(light: T, dark: T) {
if (!mounted) {
return light;
}
return colorMode === 'dark' ? dark : light;
return colorMode === "dark" ? dark : light;
}
export function ColorModeIcon() {
const { colorMode } = useColorMode();
return colorMode === 'dark' ? <LuMoon /> : <LuSun />;
return colorMode === "dark" ? <LuMoon /> : <LuSun />;
}
interface ColorModeButtonProps extends Omit<IconButtonProps, 'aria-label'> {}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<HTMLButtonElement, ColorModeButtonProps>(
function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode();
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 (
<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>
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
);
},
);
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}
/>
);
});
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}
/>
);
},
);
+21 -13
View File
@@ -1,5 +1,8 @@
import { Avatar as ChakraAvatar, AvatarGroup as ChakraAvatarGroup } from '@chakra-ui/react';
import * as React from 'react';
import {
Avatar as ChakraAvatar,
AvatarGroup as ChakraAvatarGroup,
} from "@chakra-ui/react";
import * as React from "react";
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
@@ -7,20 +10,25 @@ export interface AvatarProps extends ChakraAvatar.RootProps {
name?: string;
src?: string;
srcSet?: string;
loading?: ImageProps['loading'];
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 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;
+80 -51
View File
@@ -1,71 +1,100 @@
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';
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) {
const ClipboardIcon = React.forwardRef<
HTMLDivElement,
ChakraClipboard.IndicatorProps
>(function ClipboardIcon(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<Button ref={ref} size='sm' variant='surface' {...props}>
<ClipboardIcon />
<ClipboardCopyText />
</Button>
</ChakraClipboard.Trigger>
<ChakraClipboard.Indicator copied={<LuCheck />} {...props} ref={ref}>
<LuClipboard />
</ChakraClipboard.Indicator>
);
});
export const ClipboardLink = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardLink(props, ref) {
const ClipboardCopyText = React.forwardRef<
HTMLDivElement,
ChakraClipboard.IndicatorProps
>(function ClipboardCopyText(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>
<ChakraClipboard.Indicator copied="Copied" {...props} ref={ref}>
Copy
</ChakraClipboard.Indicator>
);
});
export const ClipboardIconButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ClipboardIconButton(props, ref) {
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>
<IconButton ref={ref} size='xs' variant='subtle' {...props}>
<Button ref={ref} size="sm" variant="surface" {...props}>
<ClipboardIcon />
<ClipboardCopyText srOnly />
</IconButton>
<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 (
+20 -16
View File
@@ -1,6 +1,6 @@
import { DataList as ChakraDataList } from '@chakra-ui/react';
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
import * as React from 'react';
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;
@@ -11,16 +11,20 @@ interface ItemProps extends ChakraDataList.ItemProps {
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>
);
});
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>
);
},
);
+20 -15
View File
@@ -1,20 +1,25 @@
import { QrCode as ChakraQrCode } from '@chakra-ui/react';
import * as React from 'react';
import { QrCode as ChakraQrCode } from "@chakra-ui/react";
import * as React from "react";
export interface QrCodeProps extends Omit<ChakraQrCode.RootProps, 'fill' | 'overlay'> {
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>
);
});
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>
);
},
);
+45 -30
View File
@@ -1,52 +1,67 @@
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';
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>
);
});
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) {
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} />)}
{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 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 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;
+30 -17
View File
@@ -1,5 +1,5 @@
import { Tag as ChakraTag } from '@chakra-ui/react';
import * as React from 'react';
import { Tag as ChakraTag } from "@chakra-ui/react";
import * as React from "react";
export interface TagProps extends ChakraTag.RootProps {
startElement?: React.ReactNode;
@@ -8,19 +8,32 @@ export interface TagProps extends ChakraTag.RootProps {
closable?: boolean;
}
export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(function Tag(props, ref) {
const { startElement, endElement, onClose, closable = !!onClose, children, ...rest } = props;
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>
);
});
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>
);
},
);
+6 -6
View File
@@ -1,14 +1,14 @@
import { Timeline as ChakraTimeline } from '@chakra-ui/react';
import * as React from 'react';
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,
) {
export const TimelineConnector = React.forwardRef<
HTMLDivElement,
TimelineConnectorProps
>(function TimelineConnector({ icon, ...props }, ref) {
return (
<ChakraTimeline.Connector ref={ref}>
<ChakraTimeline.Separator />
+37 -35
View File
@@ -1,45 +1,47 @@
import { Accordion, HStack } from '@chakra-ui/react';
import * as React from 'react';
import { LuChevronDown } from 'react-icons/lu';
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';
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>
);
},
);
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 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;
+29 -24
View File
@@ -1,34 +1,39 @@
import { Breadcrumb, type SystemStyleObject } from '@chakra-ui/react';
import * as React from 'react';
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'];
separatorGap?: SystemStyleObject["gap"];
}
export const BreadcrumbRoot = React.forwardRef<HTMLDivElement, BreadcrumbRootProps>(
function BreadcrumbRoot(props, ref) {
const { separator, separatorGap, children, ...rest } = props;
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);
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>
);
},
);
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;
+152 -124
View File
@@ -1,6 +1,6 @@
'use client';
"use client";
import type { ButtonProps, TextProps } from '@chakra-ui/react';
import type { ButtonProps, TextProps } from "@chakra-ui/react";
import {
Button,
Pagination as ChakraPagination,
@@ -8,150 +8,177 @@ import {
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';
} 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'];
current: ButtonProps["variant"];
default: ButtonProps["variant"];
ellipsis: ButtonProps["variant"];
}
type PaginationVariant = 'outline' | 'solid' | 'subtle';
type PaginationVariant = "outline" | "solid" | "subtle";
interface ButtonVariantContext {
size: ButtonProps['size'];
size: ButtonProps["size"];
variantMap: ButtonVariantMap;
getHref?: (page: number) => string;
}
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
name: 'RootPropsProvider',
name: "RootPropsProvider",
});
export interface PaginationRootProps extends Omit<ChakraPagination.RootProps, 'type'> {
size?: ButtonProps['size'];
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' },
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;
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 (
<RootPropsProvider value={{ size, variantMap: variantMap[variant], getHref }}>
<ChakraPagination.Root ref={ref} type={getHref ? 'link' : 'button'} {...rest} />
</RootPropsProvider>
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
{props.value}
</LinkButton>
);
},
);
}
export const PaginationEllipsis = React.forwardRef<HTMLDivElement, ChakraPagination.EllipsisProps>(
function PaginationEllipsis(props, ref) {
const { size, variantMap } = useRootProps();
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 (
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
<Button as='span' variant={variantMap.ellipsis} size={size}>
<HiMiniEllipsisHorizontal />
</Button>
</ChakraPagination.Ellipsis>
<LinkButton
href={previousPage != null ? getHref(previousPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronLeft />
</LinkButton>
);
},
);
}
export const PaginationItem = React.forwardRef<HTMLButtonElement, ChakraPagination.ItemProps>(
function PaginationItem(props, ref) {
const { page } = usePaginationContext();
const { size, variantMap, getHref } = useRootProps();
return (
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronLeft />
</IconButton>
</ChakraPagination.PrevTrigger>
);
});
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>
);
}
export const PaginationNextTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.NextTriggerProps
>(function PaginationNextTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps();
const { nextPage } = usePaginationContext();
if (getHref) {
return (
<ChakraPagination.Item ref={ref} {...props} asChild>
<Button variant={variant} size={size}>
{props.value}
</Button>
</ChakraPagination.Item>
<LinkButton
href={nextPage != null ? getHref(nextPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronRight />
</LinkButton>
);
},
);
}
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>
);
},
);
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' ? (
return page.type === "ellipsis" ? (
<PaginationEllipsis key={index} index={index} {...props} />
) : (
<PaginationItem key={index} type='page' value={page.value} {...props} />
<PaginationItem
key={index}
type="page"
value={page.value}
{...props}
/>
);
})
}
@@ -160,23 +187,24 @@ export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
};
interface PageTextProps extends TextProps {
format?: 'short' | 'compact' | 'long';
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]);
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>
);
},
);
return (
<Text fontWeight="medium" ref={ref} {...rest}>
{content}
</Text>
);
});
+39 -29
View File
@@ -1,32 +1,39 @@
import { Box, Steps as ChakraSteps } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck } from 'react-icons/lu';
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 {
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>
);
});
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;
@@ -43,7 +50,9 @@ const StepInfo = (props: StepInfoProps) => {
return (
<>
{title && <ChakraSteps.Title>{title}</ChakraSteps.Title>}
{description && <ChakraSteps.Description>{description}</ChakraSteps.Description>}
{description && (
<ChakraSteps.Description>{description}</ChakraSteps.Description>
)}
</>
);
};
@@ -53,16 +62,17 @@ interface StepsIndicatorProps {
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 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;
+22 -20
View File
@@ -1,27 +1,29 @@
import { Alert as ChakraAlert } from '@chakra-ui/react';
import * as React from 'react';
import { Alert as ChakraAlert } from "@chakra-ui/react";
import * as React from "react";
export interface AlertProps extends Omit<ChakraAlert.RootProps, 'title'> {
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>
);
});
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>
);
},
);
+26 -20
View File
@@ -1,5 +1,5 @@
import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react';
import * as React from 'react';
import { EmptyState as ChakraEmptyState, VStack } from "@chakra-ui/react";
import * as React from "react";
export interface EmptyStateProps extends ChakraEmptyState.RootProps {
title: string;
@@ -7,22 +7,28 @@ export interface EmptyStateProps extends ChakraEmptyState.RootProps {
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'>
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>
<ChakraEmptyState.Description>{description}</ChakraEmptyState.Description>
</VStack>
) : (
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
)}
{children}
</ChakraEmptyState.Content>
</ChakraEmptyState.Root>
);
});
)}
{children}
</ChakraEmptyState.Content>
</ChakraEmptyState.Root>
);
},
);
+30 -25
View File
@@ -1,32 +1,37 @@
import type { SystemStyleObject } from '@chakra-ui/react';
import { AbsoluteCenter, ProgressCircle as ChakraProgressCircle } from '@chakra-ui/react';
import * as React from 'react';
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'];
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 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 ProgressCircleValueText = React.forwardRef<
HTMLDivElement,
ChakraProgressCircle.ValueTextProps
>(function ProgressCircleValueText(props, ref) {
return (
<AbsoluteCenter>
<ChakraProgressCircle.ValueText {...props} ref={ref} />
</AbsoluteCenter>
);
});
export const ProgressCircleRoot = ChakraProgressCircle.Root;
+17 -13
View File
@@ -1,22 +1,26 @@
import { Progress as ChakraProgress } from '@chakra-ui/react';
import { InfoTip } from '../overlays/toggle-tip';
import * as React from 'react';
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 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) {
export const ProgressLabel = React.forwardRef<
HTMLDivElement,
ProgressLabelProps
>(function ProgressLabel(props, ref) {
const { children, info, ...rest } = props;
return (
<ChakraProgress.Label {...rest} ref={ref}>
+36 -24
View File
@@ -1,35 +1,47 @@
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';
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'];
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 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 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;
+20 -18
View File
@@ -1,27 +1,29 @@
import type { ColorPalette } from '@chakra-ui/react';
import { Status as ChakraStatus } from '@chakra-ui/react';
import * as React from 'react';
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';
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',
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>
);
});
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>
);
},
);
+24 -9
View File
@@ -1,24 +1,39 @@
'use client';
"use client";
import { Toaster as ChakraToaster, Portal, Spinner, Stack, Toast, createToaster } from '@chakra-ui/react';
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react";
export const toaster = createToaster({
placement: 'bottom-end',
placement: "bottom-end",
pauseOnPageIdle: true,
});
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: '4' }}>
<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.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>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && <Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>}
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
+22 -11
View File
@@ -1,5 +1,5 @@
import { CheckboxCard as ChakraCheckboxCard } from '@chakra-ui/react';
import * as React from 'react';
import { CheckboxCard as ChakraCheckboxCard } from "@chakra-ui/react";
import * as React from "react";
export interface CheckboxCardProps extends ChakraCheckboxCard.RootProps {
icon?: React.ReactElement;
@@ -7,11 +7,14 @@ export interface CheckboxCardProps extends ChakraCheckboxCard.RootProps {
description?: React.ReactNode;
addon?: React.ReactNode;
indicator?: React.ReactNode | null;
indicatorPlacement?: 'start' | 'end' | 'inside';
indicatorPlacement?: "start" | "end" | "inside";
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const CheckboxCard = React.forwardRef<HTMLInputElement, CheckboxCardProps>(function CheckboxCard(props, ref) {
export const CheckboxCard = React.forwardRef<
HTMLInputElement,
CheckboxCardProps
>(function CheckboxCard(props, ref) {
const {
inputProps,
label,
@@ -19,27 +22,35 @@ export const CheckboxCard = React.forwardRef<HTMLInputElement, CheckboxCardProps
icon,
addon,
indicator = <ChakraCheckboxCard.Indicator />,
indicatorPlacement = 'end',
indicatorPlacement = "end",
...rest
} = props;
const hasContent = label || description || icon;
const ContentWrapper = indicator ? ChakraCheckboxCard.Content : React.Fragment;
const ContentWrapper = indicator
? ChakraCheckboxCard.Content
: React.Fragment;
return (
<ChakraCheckboxCard.Root {...rest}>
<ChakraCheckboxCard.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckboxCard.Control>
{indicatorPlacement === 'start' && indicator}
{indicatorPlacement === "start" && indicator}
{hasContent && (
<ContentWrapper>
{icon}
{label && <ChakraCheckboxCard.Label>{label}</ChakraCheckboxCard.Label>}
{description && <ChakraCheckboxCard.Description>{description}</ChakraCheckboxCard.Description>}
{indicatorPlacement === 'inside' && indicator}
{label && (
<ChakraCheckboxCard.Label>{label}</ChakraCheckboxCard.Label>
)}
{description && (
<ChakraCheckboxCard.Description>
{description}
</ChakraCheckboxCard.Description>
)}
{indicatorPlacement === "inside" && indicator}
</ContentWrapper>
)}
{indicatorPlacement === 'end' && indicator}
{indicatorPlacement === "end" && indicator}
</ChakraCheckboxCard.Control>
{addon && <ChakraCheckboxCard.Addon>{addon}</ChakraCheckboxCard.Addon>}
</ChakraCheckboxCard.Root>
+18 -12
View File
@@ -1,5 +1,5 @@
import { Checkbox as ChakraCheckbox } from '@chakra-ui/react';
import * as React from 'react';
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react";
import * as React from "react";
export interface CheckboxProps extends ChakraCheckbox.RootProps {
icon?: React.ReactNode;
@@ -7,13 +7,19 @@ export interface CheckboxProps extends ChakraCheckbox.RootProps {
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>
);
});
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>
);
},
);
+157 -111
View File
@@ -1,7 +1,16 @@
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';
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,
@@ -9,7 +18,11 @@ export const ColorPickerTrigger = React.forwardRef<
>(function ColorPickerTrigger(props, ref) {
const { fitContent, ...rest } = props;
return (
<ChakraColorPicker.Trigger data-fit-content={fitContent || undefined} ref={ref} {...rest}>
<ChakraColorPicker.Trigger
data-fit-content={fitContent || undefined}
ref={ref}
{...rest}
>
{props.children || <ChakraColorPicker.ValueSwatch />}
</ChakraColorPicker.Trigger>
);
@@ -17,9 +30,9 @@ export const ColorPickerTrigger = React.forwardRef<
export const ColorPickerInput = React.forwardRef<
HTMLInputElement,
Omit<ChakraColorPicker.ChannelInputProps, 'channel'>
Omit<ChakraColorPicker.ChannelInputProps, "channel">
>(function ColorHexInput(props, ref) {
return <ChakraColorPicker.ChannelInput channel='hex' ref={ref} {...props} />;
return <ChakraColorPicker.ChannelInput channel="hex" ref={ref} {...props} />;
});
interface ColorPickerContentProps extends ChakraColorPicker.ContentProps {
@@ -27,78 +40,97 @@ interface ColorPickerContentProps extends ChakraColorPicker.ContentProps {
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) {
export const ColorPickerContent = React.forwardRef<
HTMLDivElement,
ColorPickerContentProps
>(function ColorPickerContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Stack gap='1' flex='1' px='1' ref={ref} {...props}>
<ColorPickerChannelSlider channel='hue' />
<ColorPickerChannelSlider channel='alpha' />
</Stack>
<Portal disabled={!portalled} container={portalRef}>
<ChakraColorPicker.Positioner>
<ChakraColorPicker.Content ref={ref} {...rest} />
</ChakraColorPicker.Positioner>
</Portal>
);
});
export const ColorPickerArea = React.forwardRef<HTMLDivElement, ChakraColorPicker.AreaProps>(
function ColorPickerArea(props, ref) {
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 (
<ChakraColorPicker.Area ref={ref} {...props}>
<ChakraColorPicker.AreaBackground />
<ChakraColorPicker.AreaThumb />
</ChakraColorPicker.Area>
<Stack gap="1" flex="1" px="1" ref={ref} {...props}>
<ColorPickerChannelSlider channel="hue" />
<ColorPickerChannelSlider channel="alpha" />
</Stack>
);
},
);
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 ColorPickerArea = React.forwardRef<
HTMLDivElement,
ChakraColorPicker.AreaProps
>(function ColorPickerArea(props, ref) {
return (
<ChakraColorPicker.Area ref={ref} {...props}>
<ChakraColorPicker.AreaBackground />
<ChakraColorPicker.AreaThumb />
</ChakraColorPicker.Area>
);
});
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 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'];
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}>
<ChakraColorPicker.SwatchTrigger
ref={ref}
style={{ ["--color" as string]: props.value }}
{...rest}
>
{children || (
<ChakraColorPicker.Swatch boxSize={swatchSize} value={props.value}>
<ChakraColorPicker.SwatchIndicator>
@@ -110,61 +142,75 @@ export const ColorPickerSwatchTrigger = React.forwardRef<
);
});
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>
);
},
);
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'],
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 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 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;
+26 -19
View File
@@ -1,26 +1,33 @@
import { Field as ChakraField } from '@chakra-ui/react';
import * as React from 'react';
import { Field as ChakraField } from "@chakra-ui/react";
import * as React from "react";
export interface FieldProps extends Omit<ChakraField.RootProps, 'label'> {
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>
);
});
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>
);
},
);
+125 -105
View File
@@ -1,6 +1,6 @@
'use client';
"use client";
import type { ButtonProps, RecipeProps } from '@chakra-ui/react';
import type { ButtonProps, RecipeProps } from "@chakra-ui/react";
import {
Button,
FileUpload as ChakraFileUpload,
@@ -10,48 +10,51 @@ import {
Text,
useFileUploadContext,
useRecipe,
} from '@chakra-ui/react';
import * as React from 'react';
import { LuFile, LuUpload, LuX } from 'react-icons/lu';
} 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 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 {
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>
);
},
);
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;
@@ -62,87 +65,104 @@ 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;
const FileUploadItem = React.forwardRef<HTMLLIElement, FileUploadItemProps>(
function FileUploadItem(props, ref) {
const { file, showSize, clearable } = props;
return (
<ChakraFileUpload.ItemGroup ref={ref} {...rest}>
{acceptedFiles.map((file) => (
<FileUploadItem key={file.name} file={file} showSize={showSize} clearable={clearable} />
))}
</ChakraFileUpload.ItemGroup>
<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'>> {
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 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;
+42 -39
View File
@@ -1,6 +1,6 @@
import type { BoxProps, InputElementProps } from '@chakra-ui/react';
import { Group, InputElement } from '@chakra-ui/react';
import * as React from 'react';
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;
@@ -8,43 +8,46 @@ export interface InputGroupProps extends BoxProps {
startElement?: React.ReactNode;
endElement?: React.ReactNode;
children: React.ReactElement<InputElementProps>;
startOffset?: InputElementProps['paddingStart'];
endOffset?: InputElementProps['paddingEnd'];
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;
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);
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>
);
});
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>
);
},
);
+38 -33
View File
@@ -1,23 +1,24 @@
'use client';
"use client";
import { NativeSelect as Select } from '@chakra-ui/react';
import * as React from 'react';
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>
);
},
);
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;
@@ -29,24 +30,28 @@ 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;
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],
);
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>
);
},
);
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>
);
});
+7 -4
View File
@@ -1,12 +1,15 @@
import { NumberInput as ChakraNumberInput } from '@chakra-ui/react';
import * as React from 'react';
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) {
export const NumberInputRoot = React.forwardRef<
HTMLDivElement,
NumberInputProps
>(function NumberInput(props, ref) {
const { children, ...rest } = props;
return (
<ChakraNumberInput.Root ref={ref} variant='outline' {...rest}>
<ChakraNumberInput.Root ref={ref} variant="outline" {...rest}>
{children}
<ChakraNumberInput.Control>
<ChakraNumberInput.IncrementTrigger />
+89 -64
View File
@@ -1,10 +1,24 @@
'use client';
"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';
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 {
/**
@@ -25,11 +39,15 @@ export interface PasswordVisibilityProps {
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode };
}
export interface PasswordInputProps extends InputProps, PasswordVisibilityProps {
export interface PasswordInputProps
extends InputProps, PasswordVisibilityProps {
rootProps?: GroupProps;
}
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(props, ref) {
export const PasswordInput = React.forwardRef<
HTMLInputElement,
PasswordInputProps
>(function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
@@ -64,73 +82,80 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputPro
}
{...rootProps}
>
<Input {...rest} ref={mergeRefs(ref, inputRef)} type={visible ? 'text' : 'password'} />
<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}
/>
);
});
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();
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' };
}
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);
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>
);
},
);
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>
);
});
+19 -17
View File
@@ -1,5 +1,5 @@
import { PinInput as ChakraPinInput, Group } from '@chakra-ui/react';
import * as React from 'react';
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>;
@@ -8,18 +8,20 @@ export interface PinInputProps extends ChakraPinInput.RootProps {
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>
);
});
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>
);
},
);
+16 -9
View File
@@ -1,5 +1,5 @@
import { RadioCard } from '@chakra-ui/react';
import * as React from 'react';
import { RadioCard } from "@chakra-ui/react";
import * as React from "react";
interface RadioCardItemProps extends RadioCard.ItemProps {
icon?: React.ReactElement;
@@ -7,11 +7,14 @@ interface RadioCardItemProps extends RadioCard.ItemProps {
description?: React.ReactNode;
addon?: React.ReactNode;
indicator?: React.ReactNode | null;
indicatorPlacement?: 'start' | 'end' | 'inside';
indicatorPlacement?: "start" | "end" | "inside";
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const RadioCardItem = React.forwardRef<HTMLInputElement, RadioCardItemProps>(function RadioCardItem(props, ref) {
export const RadioCardItem = React.forwardRef<
HTMLInputElement,
RadioCardItemProps
>(function RadioCardItem(props, ref) {
const {
inputProps,
label,
@@ -19,7 +22,7 @@ export const RadioCardItem = React.forwardRef<HTMLInputElement, RadioCardItemPro
addon,
icon,
indicator = <RadioCard.ItemIndicator />,
indicatorPlacement = 'end',
indicatorPlacement = "end",
...rest
} = props;
@@ -30,16 +33,20 @@ export const RadioCardItem = React.forwardRef<HTMLInputElement, RadioCardItemPro
<RadioCard.Item {...rest}>
<RadioCard.ItemHiddenInput ref={ref} {...inputProps} />
<RadioCard.ItemControl>
{indicatorPlacement === 'start' && indicator}
{indicatorPlacement === "start" && indicator}
{hasContent && (
<ContentWrapper>
{icon}
{label && <RadioCard.ItemText>{label}</RadioCard.ItemText>}
{description && <RadioCard.ItemDescription>{description}</RadioCard.ItemDescription>}
{indicatorPlacement === 'inside' && indicator}
{description && (
<RadioCard.ItemDescription>
{description}
</RadioCard.ItemDescription>
)}
{indicatorPlacement === "inside" && indicator}
</ContentWrapper>
)}
{indicatorPlacement === 'end' && indicator}
{indicatorPlacement === "end" && indicator}
</RadioCard.ItemControl>
{addon && <RadioCard.ItemAddon>{addon}</RadioCard.ItemAddon>}
</RadioCard.Item>
+16 -12
View File
@@ -1,20 +1,24 @@
import { RadioGroup as ChakraRadioGroup } from '@chakra-ui/react';
import * as React from 'react';
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 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;
+20 -18
View File
@@ -1,5 +1,5 @@
import { RatingGroup } from '@chakra-ui/react';
import * as React from 'react';
import { RatingGroup } from "@chakra-ui/react";
import * as React from "react";
export interface RatingProps extends RatingGroup.RootProps {
icon?: React.ReactElement;
@@ -7,19 +7,21 @@ export interface RatingProps extends RatingGroup.RootProps {
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>
);
});
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>
);
},
);
+28 -23
View File
@@ -1,7 +1,7 @@
'use client';
"use client";
import { For, SegmentGroup } from '@chakra-ui/react';
import * as React from 'react';
import { For, SegmentGroup } from "@chakra-ui/react";
import * as React from "react";
interface Item {
value: string;
@@ -15,28 +15,33 @@ export interface SegmentedControlProps extends SegmentGroup.RootProps {
function normalize(items: Array<string | Item>): Item[] {
return items.map((item) => {
if (typeof item === 'string') return { value: item, label: 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]);
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>
);
},
);
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>
);
});
+52 -46
View File
@@ -1,5 +1,5 @@
import { Slider as ChakraSlider, For, HStack } from '@chakra-ui/react';
import * as React from 'react';
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 }>;
@@ -8,36 +8,40 @@ export interface SliderProps extends ChakraSlider.RootProps {
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;
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 marks = marksProp?.map((mark) => {
if (typeof mark === "number") return { value: mark, label: undefined };
return mark;
});
const hasMarkLabel = !!marks?.some((mark) => mark.label);
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'>
return (
<ChakraSlider.Root ref={ref} thumbAlignment="center" {...rest}>
{label && !showValue && (
<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>
);
});
)}
{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;
@@ -57,22 +61,24 @@ 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;
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>
);
});
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>
);
},
);
+40 -36
View File
@@ -1,45 +1,49 @@
import { HStack, IconButton, NumberInput } from '@chakra-ui/react';
import * as React from 'react';
import { LuMinus, LuPlus } from 'react-icons/lu';
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;
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.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>
<NumberInput.DecrementTrigger {...props} asChild ref={ref}>
<IconButton variant="outline" size="sm">
<LuMinus />
</IconButton>
</NumberInput.DecrementTrigger>
);
});
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>
);
},
);
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>
);
});
+29 -18
View File
@@ -1,5 +1,5 @@
import { Switch as ChakraSwitch } from '@chakra-ui/react';
import * as React from 'react';
import { Switch as ChakraSwitch } from "@chakra-ui/react";
import * as React from "react";
export interface SwitchProps extends ChakraSwitch.RootProps {
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
@@ -8,21 +8,32 @@ export interface SwitchProps extends ChakraSwitch.RootProps {
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;
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>
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.Thumb>
{trackLabel && <ChakraSwitch.Indicator fallback={trackLabel.off}>{trackLabel.on}</ChakraSwitch.Indicator>}
</ChakraSwitch.Control>
{children != null && <ChakraSwitch.Label>{children}</ChakraSwitch.Label>}
</ChakraSwitch.Root>
);
});
</ChakraSwitch.Control>
{children != null && (
<ChakraSwitch.Label>{children}</ChakraSwitch.Label>
)}
</ChakraSwitch.Root>
);
},
);
+16 -16
View File
@@ -1,17 +1,17 @@
'use client';
"use client";
import React, { useTransition } from 'react';
import { Locale, useLocale } from 'next-intl';
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, ClientOnly } from '@chakra-ui/react';
import { usePathname, useRouter } from '@/i18n/navigation';
} from "@/components/ui/collections/select";
import { useParams } from "next/navigation";
import { createListCollection, ClientOnly } from "@chakra-ui/react";
import { usePathname, useRouter } from "@/i18n/navigation";
const LocaleSwitcher = () => {
const locale = useLocale();
@@ -22,8 +22,8 @@ const LocaleSwitcher = () => {
const collections = createListCollection({
items: [
{ label: 'English', value: 'en' },
{ label: 'Türkçe', value: 'tr' },
{ label: "English", value: "en" },
{ label: "Türkçe", value: "tr" },
],
});
@@ -40,21 +40,21 @@ const LocaleSwitcher = () => {
});
}
return (
<ClientOnly fallback={<div style={{ height: '32px' }} />}>
<ClientOnly fallback={<div style={{ height: "32px" }} />}>
<SelectRoot
disabled={isPending}
value={[locale]}
onValueChange={onSelectChange}
w={{ base: 'full', lg: '24' }}
size='sm'
variant='outline'
borderRadius='md'
w={{ base: "full", lg: "24" }}
size="sm"
variant="outline"
borderRadius="md"
collection={collections}
>
<SelectTrigger>
<SelectValueText placeholder='Select a language' />
<SelectValueText placeholder="Select a language" />
</SelectTrigger>
<SelectContent zIndex='9999'>
<SelectContent zIndex="9999">
{collections.items.map((collection) => (
<SelectItem key={collection.value} item={collection}>
{collection.label}
+28 -26
View File
@@ -1,37 +1,39 @@
import { ActionBar, Portal } from '@chakra-ui/react';
import { CloseButton } from '@/components/ui/buttons/close-button';
import * as React from 'react';
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;
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>
);
},
);
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 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;
+32 -16
View File
@@ -1,6 +1,6 @@
import { Dialog as ChakraDialog, Portal } from '@chakra-ui/react';
import { CloseButton } from '@/components/ui/buttons/close-button';
import * as React from 'react';
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;
@@ -8,8 +8,17 @@ interface DialogContentProps extends ChakraDialog.ContentProps {
backdrop?: boolean;
}
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(function DialogContent(props, ref) {
const { children, portalled = true, portalRef, backdrop = true, ...rest } = props;
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}>
@@ -23,17 +32,24 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
);
});
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 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;
+24 -14
View File
@@ -1,14 +1,17 @@
import { Drawer as ChakraDrawer, Portal } from '@chakra-ui/react';
import { CloseButton } from '@/components/ui/buttons/close-button';
import * as React from 'react';
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'];
offset?: ChakraDrawer.ContentProps["padding"];
}
export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(function DrawerContent(props, ref) {
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}>
@@ -21,15 +24,22 @@ export const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps
);
});
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 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;
+25 -23
View File
@@ -1,34 +1,36 @@
import { HoverCard, Portal } from '@chakra-ui/react';
import * as React from 'react';
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;
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>
);
},
);
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 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;
+80 -67
View File
@@ -1,26 +1,31 @@
'use client';
"use client";
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck, LuChevronRight } from 'react-icons/lu';
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 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) {
export const MenuArrow = React.forwardRef<
HTMLDivElement,
ChakraMenu.ArrowProps
>(function MenuArrow(props, ref) {
return (
<ChakraMenu.Arrow ref={ref} {...props}>
<ChakraMenu.ArrowTip />
@@ -28,65 +33,73 @@ export const MenuArrow = React.forwardRef<HTMLDivElement, ChakraMenu.ArrowProps>
);
});
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 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 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 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 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;
+43 -33
View File
@@ -1,44 +1,54 @@
import { Popover as ChakraPopover, Portal } from '@chakra-ui/react';
import { CloseButton } from '../buttons/close-button';
import * as React from 'react';
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 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 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 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;
+67 -33
View File
@@ -1,6 +1,11 @@
import { Popover as ChakraPopover, IconButton, type IconButtonProps, Portal } from '@chakra-ui/react';
import * as React from 'react';
import { HiOutlineInformationCircle } from 'react-icons/hi';
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;
@@ -10,39 +15,68 @@ export interface ToggleTipProps extends ChakraPopover.RootProps {
contentProps?: ChakraPopover.ContentProps;
}
export const ToggleTip = React.forwardRef<HTMLDivElement, ToggleTipProps>(function ToggleTip(props, ref) {
const { showArrow, children, portalled = true, content, contentProps, portalRef, ...rest } = props;
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>
);
});
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>
);
});
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>
);
},
);
+34 -23
View File
@@ -1,5 +1,5 @@
import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react';
import * as React from 'react';
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react";
import * as React from "react";
export interface TooltipProps extends ChakraTooltip.RootProps {
showArrow?: boolean;
@@ -10,26 +10,37 @@ export interface TooltipProps extends ChakraTooltip.RootProps {
disabled?: boolean;
}
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(function Tooltip(props, ref) {
const { showArrow, children, disabled, portalled = true, content, contentProps, portalRef, ...rest } = props;
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;
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>
);
});
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>
);
},
);
+8 -5
View File
@@ -8,17 +8,20 @@ import { Toaster } from "./feedback/toaster";
import TopLoader from "./top-loader";
import ReactQueryProvider from "@/providers/react-query-provider";
import AOSProvider from "@/providers/aos-provider";
import { PaddleProvider } from "@/lib/paddle/paddle-provider";
export function Provider(props: ColorModeProviderProps) {
return (
<SessionProvider>
<ChakraProvider value={system}>
<ReactQueryProvider>
<AOSProvider>
<TopLoader />
<ColorModeProvider {...props} />
<Toaster />
</AOSProvider>
<PaddleProvider>
<AOSProvider>
<TopLoader />
<ColorModeProvider {...props} />
<Toaster />
</AOSProvider>
</PaddleProvider>
</ReactQueryProvider>
</ChakraProvider>
</SessionProvider>
+4 -4
View File
@@ -1,10 +1,10 @@
'use client';
"use client";
import NextTopLoader from 'nextjs-toploader';
import { useToken } from '@chakra-ui/react';
import NextTopLoader from "nextjs-toploader";
import { useToken } from "@chakra-ui/react";
export default function TopLoader() {
const [color] = useToken('colors', ['primary.500']);
const [color] = useToken("colors", ["primary.500"]);
return <NextTopLoader color={color} showSpinner={false} />;
}
+20 -16
View File
@@ -1,5 +1,5 @@
import { Blockquote as ChakraBlockquote } from '@chakra-ui/react';
import * as React from 'react';
import { Blockquote as ChakraBlockquote } from "@chakra-ui/react";
import * as React from "react";
export interface BlockquoteProps extends ChakraBlockquote.RootProps {
cite?: React.ReactNode;
@@ -8,20 +8,24 @@ export interface BlockquoteProps extends ChakraBlockquote.RootProps {
showDash?: boolean;
}
export const Blockquote = React.forwardRef<HTMLDivElement, BlockquoteProps>(function Blockquote(props, ref) {
const { children, cite, citeUrl, showDash, icon, ...rest } = props;
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 ? <>&mdash;</> : null} <cite>{cite}</cite>
</ChakraBlockquote.Caption>
)}
</ChakraBlockquote.Root>
);
});
return (
<ChakraBlockquote.Root ref={ref} {...rest}>
{icon}
<ChakraBlockquote.Content cite={citeUrl}>
{children}
</ChakraBlockquote.Content>
{cite && (
<ChakraBlockquote.Caption>
{showDash ? <>&mdash;</> : null} <cite>{cite}</cite>
</ChakraBlockquote.Caption>
)}
</ChakraBlockquote.Root>
);
},
);
export const BlockquoteIcon = ChakraBlockquote.Icon;
+205 -203
View File
@@ -1,275 +1,277 @@
'use client';
"use client";
import { chakra } from '@chakra-ui/react';
import { chakra } from "@chakra-ui/react";
const TRAILING_PSEUDO_REGEX = /(::?[\w-]+(?:\([^)]*\))?)+$/;
const EXCLUDE_CLASSNAME = '.not-prose';
const EXCLUDE_CLASSNAME = ".not-prose";
function inWhere<T extends string>(selector: T): T {
const rebuiltSelector = selector.startsWith('& ') ? selector.slice(2) : selector;
const rebuiltSelector = selector.startsWith("& ")
? selector.slice(2)
: selector;
const match = selector.match(TRAILING_PSEUDO_REGEX);
const pseudo = match ? match[0] : '';
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', {
export const Prose = chakra("div", {
base: {
color: 'fg.muted',
maxWidth: '65ch',
fontSize: 'sm',
lineHeight: '1.7em',
[inWhere('& p')]: {
marginTop: '1em',
marginBottom: '1em',
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("& 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("& a")]: {
color: "fg",
textDecoration: "underline",
textUnderlineOffset: "3px",
textDecorationThickness: "2px",
textDecorationColor: "border.muted",
fontWeight: "500",
},
[inWhere('& strong')]: {
fontWeight: '600',
[inWhere("& strong")]: {
fontWeight: "600",
},
[inWhere('& a strong')]: {
color: 'inherit',
[inWhere("& a strong")]: {
color: "inherit",
},
[inWhere('& h1')]: {
fontSize: '2.15em',
letterSpacing: '-0.02em',
marginTop: '0',
marginBottom: '0.8em',
lineHeight: '1.2em',
[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("& 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("& 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("& 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("& img")]: {
marginTop: "1.7em",
marginBottom: "1.7em",
borderRadius: "lg",
boxShadow: "inset",
},
[inWhere('& picture')]: {
marginTop: '1.7em',
marginBottom: '1.7em',
[inWhere("& picture")]: {
marginTop: "1.7em",
marginBottom: "1.7em",
},
[inWhere('& picture > img')]: {
marginTop: '0',
marginBottom: '0',
[inWhere("& picture > img")]: {
marginTop: "0",
marginBottom: "0",
},
[inWhere('& video')]: {
marginTop: '1.7em',
marginBottom: '1.7em',
[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("& 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("& 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("& pre code")]: {
fontSize: "inherit",
letterSpacing: "inherit",
borderWidth: "inherit",
padding: "0",
},
[inWhere('& h2 code')]: {
fontSize: '0.9em',
[inWhere("& h2 code")]: {
fontSize: "0.9em",
},
[inWhere('& h3 code')]: {
fontSize: '0.8em',
[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("& 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("& ol")]: {
marginTop: "1em",
marginBottom: "1em",
paddingInlineStart: "1.5em",
},
[inWhere('& ul')]: {
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("& li")]: {
marginTop: "0.285em",
marginBottom: "0.285em",
},
[inWhere('& ol > li')]: {
paddingInlineStart: '0.4em',
listStyleType: 'decimal',
'&::marker': {
color: 'fg.muted',
[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")]: {
paddingInlineStart: "0.4em",
listStyleType: "disc",
"&::marker": {
color: "fg.muted",
},
},
[inWhere('& > ul > li p')]: {
marginTop: '0.5em',
marginBottom: '0.5em',
[inWhere("& > ul > li p")]: {
marginTop: "0.5em",
marginBottom: "0.5em",
},
[inWhere('& > ul > li > p:first-of-type')]: {
marginTop: '1em',
[inWhere("& > ul > li > p:first-of-type")]: {
marginTop: "1em",
},
[inWhere('& > ul > li > p:last-of-type')]: {
marginBottom: '1em',
[inWhere("& > ul > li > p:last-of-type")]: {
marginBottom: "1em",
},
[inWhere('& > ol > li > p:first-of-type')]: {
marginTop: '1em',
[inWhere("& > ol > li > p:first-of-type")]: {
marginTop: "1em",
},
[inWhere('& > ol > li > p:last-of-type')]: {
marginBottom: '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("& ul ul, ul ol, ol ul, ol ol")]: {
marginTop: "0.5em",
marginBottom: "0.5em",
},
[inWhere('& dl')]: {
marginTop: '1em',
marginBottom: '1em',
[inWhere("& dl")]: {
marginTop: "1em",
marginBottom: "1em",
},
[inWhere('& dt')]: {
fontWeight: '600',
marginTop: '1em',
[inWhere("& dt")]: {
fontWeight: "600",
marginTop: "1em",
},
[inWhere('& dd')]: {
marginTop: '0.285em',
paddingInlineStart: '1.5em',
[inWhere("& dd")]: {
marginTop: "0.285em",
paddingInlineStart: "1.5em",
},
[inWhere('& hr')]: {
marginTop: '2.25em',
marginBottom: '2.25em',
[inWhere("& hr")]: {
marginTop: "2.25em",
marginBottom: "2.25em",
},
[inWhere('& :is(h1,h2,h3,h4,h5,hr) + *')]: {
marginTop: '0',
[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("& table")]: {
width: "100%",
tableLayout: "auto",
textAlign: "start",
lineHeight: "1.5em",
marginTop: "2em",
marginBottom: "2em",
},
[inWhere('& thead')]: {
borderBottomWidth: '1px',
color: 'fg',
[inWhere("& thead")]: {
borderBottomWidth: "1px",
color: "fg",
},
[inWhere('& tbody tr')]: {
borderBottomWidth: '1px',
borderBottomColor: 'border',
[inWhere("& tbody tr")]: {
borderBottomWidth: "1px",
borderBottomColor: "border",
},
[inWhere('& thead th')]: {
paddingInlineEnd: '1em',
paddingBottom: '0.65em',
paddingInlineStart: '1em',
fontWeight: 'medium',
textAlign: 'start',
[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:first-of-type")]: {
paddingInlineStart: "0",
},
[inWhere('& thead th:last-of-type')]: {
paddingInlineEnd: '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, 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:first-of-type, tfoot td:first-of-type")]: {
paddingInlineStart: "0",
},
[inWhere('& tbody td:last-of-type, tfoot td:last-of-type')]: {
paddingInlineEnd: '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: "1.625em",
marginBottom: "1.625em",
},
[inWhere('& figure > *')]: {
marginTop: '0',
marginBottom: '0',
[inWhere("& figure > *")]: {
marginTop: "0",
marginBottom: "0",
},
[inWhere('& figcaption')]: {
fontSize: '0.85em',
lineHeight: '1.25em',
marginTop: '0.85em',
color: 'fg.muted',
[inWhere("& figcaption")]: {
fontSize: "0.85em",
lineHeight: "1.25em",
marginTop: "0.85em",
color: "fg.muted",
},
[inWhere('& h1, h2, h3, h4')]: {
color: 'fg',
fontWeight: '600',
[inWhere("& h1, h2, h3, h4")]: {
color: "fg",
fontWeight: "600",
},
},
variants: {
size: {
md: {
fontSize: 'sm',
fontSize: "sm",
},
lg: {
fontSize: 'md',
fontSize: "md",
},
},
},
defaultVariants: {
size: 'md',
size: "md",
},
});