This commit is contained in:
@@ -13,8 +13,10 @@ import {
|
||||
Spinner,
|
||||
Button,
|
||||
Separator,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NativeSelectRoot, NativeSelectField } from "@/components/ui/forms/native-select";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||||
import {
|
||||
SlideUp,
|
||||
@@ -25,9 +27,10 @@ import {
|
||||
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
||||
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
||||
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
|
||||
import { LuUsers, LuChartBar, LuActivity, LuShield } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
import { LuUsers, LuChartBar, LuActivity, LuShield, LuPencil } from "react-icons/lu";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { EditUserModal } from "./edit-user-modal";
|
||||
|
||||
type AdminTab = "overview" | "users";
|
||||
|
||||
@@ -82,7 +85,20 @@ function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
|
||||
export default function AdminContent() {
|
||||
const t = useTranslations("admin");
|
||||
const tCommon = useTranslations("common");
|
||||
const format = useFormatter();
|
||||
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
|
||||
const [editingUser, setEditingUser] = useState<AdminUserDto | null>(null);
|
||||
const [searchParams, setSearchParams] = useState({ search: "", role: "", subscriptionStatus: "", page: 1, limit: 10 });
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(searchParams.search);
|
||||
setSearchParams(prev => ({ ...prev, page: 1 }));
|
||||
}, 500);
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchParams.search]);
|
||||
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
@@ -92,12 +108,19 @@ export default function AdminContent() {
|
||||
const { data: analyticsData, isLoading: analyticsLoading } =
|
||||
useAdminAnalytics(canAccessAdmin);
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers(
|
||||
undefined,
|
||||
{
|
||||
search: debouncedSearch,
|
||||
role: searchParams.role,
|
||||
subscriptionStatus: searchParams.subscriptionStatus,
|
||||
page: searchParams.page,
|
||||
limit: searchParams.limit
|
||||
},
|
||||
canAccessAdmin,
|
||||
);
|
||||
|
||||
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
||||
const users = usersData?.data?.items ?? [];
|
||||
const meta = usersData?.data?.meta;
|
||||
|
||||
const tabs: { key: AdminTab; label: string }[] = [
|
||||
{ key: "overview", label: t("overview") },
|
||||
@@ -242,113 +265,215 @@ export default function AdminContent() {
|
||||
))}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === "users" &&
|
||||
(usersLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : users.length > 0 ? (
|
||||
{activeTab === "users" && (
|
||||
<VStack gap={4} align="stretch">
|
||||
{/* Filters */}
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<VStack gap={0} align="stretch">
|
||||
{/* Table Header */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
mb={2}
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text flex={2}>{t("user-name")}</Text>
|
||||
<Text flex={2}>{t("user-email")}</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-role")}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("subscription", { fallback: "Subscription" })}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-status")}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* User Rows */}
|
||||
{users.map((user: AdminUserDto, idx: number) => (
|
||||
<Box key={user.id ?? idx}>
|
||||
{idx > 0 && <Separator />}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
align="center"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Text
|
||||
flex={2}
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
truncate
|
||||
>
|
||||
{getUserDisplayName(user)}
|
||||
</Text>
|
||||
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={
|
||||
isAdminRole([user.role]) ? "red" : "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{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"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.isActive
|
||||
? tCommon("active")
|
||||
: tCommon("inactive")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
<Card.Body py={4}>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} gap={4}>
|
||||
<Input
|
||||
placeholder="E-posta veya isim ara..."
|
||||
value={searchParams.search}
|
||||
onChange={(e) => setSearchParams({ ...searchParams, search: e.target.value })}
|
||||
/>
|
||||
<NativeSelectRoot>
|
||||
<NativeSelectField
|
||||
placeholder="Tüm Rolleri Gör"
|
||||
value={searchParams.role}
|
||||
onChange={(e) => setSearchParams({ ...searchParams, role: e.target.value, page: 1 })}
|
||||
items={[
|
||||
{ label: "Standart Kullanıcı", value: "user" },
|
||||
{ label: "Admin", value: "superadmin" }
|
||||
]}
|
||||
/>
|
||||
</NativeSelectRoot>
|
||||
<NativeSelectRoot>
|
||||
<NativeSelectField
|
||||
placeholder="Tüm Paketleri Gör"
|
||||
value={searchParams.subscriptionStatus}
|
||||
onChange={(e) => setSearchParams({ ...searchParams, subscriptionStatus: e.target.value, page: 1 })}
|
||||
items={[
|
||||
{ label: "Ücretsiz (Free)", value: "free" },
|
||||
{ label: "Plus", value: "plus" },
|
||||
{ label: "Premium", value: "premium" },
|
||||
{ label: "Gecikmiş", value: "past_due" },
|
||||
{ label: "İptal", value: "cancelled" }
|
||||
]}
|
||||
/>
|
||||
</NativeSelectRoot>
|
||||
</SimpleGrid>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-users")}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
{usersLoading ? (
|
||||
<Flex justify="center" py={16}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
</Flex>
|
||||
) : users.length > 0 ? (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||||
<Card.Body>
|
||||
<VStack gap={0} align="stretch">
|
||||
{/* Table Header */}
|
||||
<Flex
|
||||
px={4}
|
||||
py={2}
|
||||
bg="bg.muted"
|
||||
borderRadius="lg"
|
||||
mb={2}
|
||||
fontWeight="semibold"
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
>
|
||||
<Text flex={2}>{t("user-name")}</Text>
|
||||
<Text flex={2}>{t("user-email")}</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-role")}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("subscription", { fallback: "Subscription" })}
|
||||
</Text>
|
||||
<Text flex={1} textAlign="center">
|
||||
{t("user-status")}
|
||||
</Text>
|
||||
<Text width="40px" textAlign="center"></Text>
|
||||
</Flex>
|
||||
|
||||
{/* User Rows */}
|
||||
{users.map((user: AdminUserDto, idx: number) => (
|
||||
<Box key={user.id ?? idx}>
|
||||
{idx > 0 && <Separator />}
|
||||
<Flex
|
||||
px={4}
|
||||
py={3}
|
||||
align="center"
|
||||
_hover={{ bg: "bg.muted" }}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Text
|
||||
flex={2}
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
truncate
|
||||
>
|
||||
{getUserDisplayName(user)}
|
||||
</Text>
|
||||
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Flex flex={1} justify="center">
|
||||
<Badge
|
||||
colorPalette={
|
||||
isAdminRole([user.role]) ? "red" : "gray"
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{formatRoleLabel(user.role)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Flex flex={1} justify="center" direction="column" align="center" gap={1}>
|
||||
<Badge
|
||||
colorPalette={user.subscriptionStatus === "premium" || user.subscriptionStatus === "plus" ? "purple" : "gray"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
textTransform="capitalize"
|
||||
>
|
||||
{user.subscriptionStatus || "free"}
|
||||
</Badge>
|
||||
{user.subscriptionExpiresAt ? (
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
{format.dateTime(new Date(user.subscriptionExpiresAt), { year: 'numeric', month: '2-digit', day: '2-digit' })}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="2xs" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</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"}
|
||||
variant="subtle"
|
||||
fontSize="2xs"
|
||||
borderRadius="full"
|
||||
>
|
||||
{user.isActive
|
||||
? tCommon("active")
|
||||
: tCommon("inactive")}
|
||||
</Badge>
|
||||
</Flex>
|
||||
<Flex width="40px" justify="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingUser(user)}
|
||||
>
|
||||
<LuPencil />
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Pagination */}
|
||||
{meta && meta.totalPages > 1 && (
|
||||
<Flex justify="center" pt={4} pb={2} gap={2} borderTopWidth="1px" borderColor={borderColor} mt={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!meta.hasPreviousPage}
|
||||
onClick={() => setSearchParams({ ...searchParams, page: meta.page - 1 })}
|
||||
>
|
||||
Önceki
|
||||
</Button>
|
||||
<Flex align="center" gap={2} fontSize="sm">
|
||||
<Text>Sayfa {meta.page} / {meta.totalPages}</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!meta.hasNextPage}
|
||||
onClick={() => setSearchParams({ ...searchParams, page: meta.page + 1 })}
|
||||
>
|
||||
Sonraki
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Flex justify="center" py={16}>
|
||||
<Text color="fg.muted">{t("no-users")}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
/>
|
||||
</Box>
|
||||
</SlideUp>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
VStack,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogCloseTrigger,
|
||||
} from "@/components/ui/overlays/dialog";
|
||||
import { Field } from "@/components/ui/forms/field";
|
||||
import { NativeSelectRoot, NativeSelectField } from "@/components/ui/forms/native-select";
|
||||
import { Switch } from "@/components/ui/forms/switch";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AdminUserDto } from "@/lib/api/admin/types";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
useUpdateUserRole,
|
||||
useUpdateUserSubscription,
|
||||
useToggleUserActive,
|
||||
} from "@/lib/api/admin/use-hooks";
|
||||
|
||||
interface EditUserModalProps {
|
||||
user: AdminUserDto | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EditUserModal({ user, isOpen, onClose }: EditUserModalProps) {
|
||||
const t = useTranslations("admin");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [role, setRole] = useState("user");
|
||||
const [plan, setPlan] = useState("free");
|
||||
const [expiresAt, setExpiresAt] = useState<string>("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
const { mutateAsync: updateRole, isPending: rolePending } = useUpdateUserRole();
|
||||
const { mutateAsync: updateSub, isPending: subPending } = useUpdateUserSubscription();
|
||||
const { mutateAsync: toggleActive, isPending: togglePending } = useToggleUserActive();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setRole(user.role || "user");
|
||||
setPlan(user.subscriptionStatus || "free");
|
||||
setIsActive(user.isActive);
|
||||
if (user.subscriptionExpiresAt) {
|
||||
try {
|
||||
const date = new Date(user.subscriptionExpiresAt);
|
||||
setExpiresAt(date.toISOString().split('T')[0]);
|
||||
} catch(e) { setExpiresAt(""); }
|
||||
} else {
|
||||
setExpiresAt("");
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
if (role !== user.role) {
|
||||
await updateRole({ id: user.id, dto: { role } });
|
||||
}
|
||||
|
||||
const currentExpiresAtStr = user.subscriptionExpiresAt
|
||||
? new Date(user.subscriptionExpiresAt).toISOString().split('T')[0]
|
||||
: "";
|
||||
|
||||
if (plan !== user.subscriptionStatus || expiresAt !== currentExpiresAtStr) {
|
||||
await updateSub({
|
||||
id: user.id,
|
||||
dto: {
|
||||
plan,
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null
|
||||
}
|
||||
});
|
||||
}
|
||||
if (isActive !== user.isActive) {
|
||||
await toggleActive(user.id);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = rolePending || subPending || togglePending;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kullanıcı Düzenle: {user.email}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<VStack gap={4} align="stretch">
|
||||
<Field label="Kullanıcı Rolü">
|
||||
<NativeSelectRoot>
|
||||
<NativeSelectField
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
items={[
|
||||
{ label: "Standart Kullanıcı", value: "user" },
|
||||
{ label: "Sistem Yöneticisi (Admin)", value: "superadmin" },
|
||||
]}
|
||||
/>
|
||||
</NativeSelectRoot>
|
||||
</Field>
|
||||
|
||||
<Field label="Abonelik Paketi">
|
||||
<NativeSelectRoot>
|
||||
<NativeSelectField
|
||||
value={plan}
|
||||
onChange={(e) => {
|
||||
const newPlan = e.target.value;
|
||||
setPlan(newPlan);
|
||||
if ((newPlan === "premium" || newPlan === "plus") && !expiresAt) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
setExpiresAt(d.toISOString().split('T')[0]);
|
||||
} else if (newPlan === "free" || newPlan === "cancelled" || newPlan === "past_due") {
|
||||
setExpiresAt("");
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{ label: "Ücretsiz (Free)", value: "free" },
|
||||
{ label: "Plus Paketi", value: "plus" },
|
||||
{ label: "Premium Paketi", value: "premium" },
|
||||
{ label: "Ödeme Gecikti (Past Due)", value: "past_due" },
|
||||
{ label: "İptal Edildi (Cancelled)", value: "cancelled" },
|
||||
]}
|
||||
/>
|
||||
</NativeSelectRoot>
|
||||
</Field>
|
||||
|
||||
{plan !== "free" && (
|
||||
<Field label="Abonelik Bitiş Tarihi (Opsiyonel)">
|
||||
<Input
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Hesap Aktif mi?">
|
||||
<Switch checked={isActive} onCheckedChange={(e) => setIsActive(e.checked)}>
|
||||
{isActive ? "Aktif" : "Pasif"}
|
||||
</Switch>
|
||||
</Field>
|
||||
</VStack>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
İptal
|
||||
</Button>
|
||||
<Button colorPalette="primary" onClick={handleSave} loading={isPending}>
|
||||
Kaydet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default function Footer() {
|
||||
focusRing="none"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
Suggest Bet
|
||||
iddaai
|
||||
</ChakraLink>
|
||||
. {t("all-right-reserved")}
|
||||
</Text>
|
||||
|
||||
@@ -246,27 +246,34 @@ export default function MatchDetailContent() {
|
||||
{/* Score */}
|
||||
<VStack gap={1} flexShrink={0}>
|
||||
{match.score && (isLive || isFinished) ? (
|
||||
<HStack gap={3}>
|
||||
<Text
|
||||
fontSize="5xl"
|
||||
fontWeight="900"
|
||||
lineHeight="1"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.home}
|
||||
</Text>
|
||||
<Text fontSize="2xl" color="fg.muted" fontWeight="300">
|
||||
:
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="5xl"
|
||||
fontWeight="900"
|
||||
lineHeight="1"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.away}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<>
|
||||
<HStack gap={3}>
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.home}
|
||||
</Text>
|
||||
<Text fontSize="2xl" color="fg.muted">
|
||||
-
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="4xl"
|
||||
fontWeight="900"
|
||||
color={isLive ? "red.500" : "fg"}
|
||||
>
|
||||
{match.score.away}
|
||||
</Text>
|
||||
</HStack>
|
||||
{match.score.htHome != null &&
|
||||
match.score.htAway != null && (
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
(HT: {match.score.htHome}-{match.score.htAway})
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text fontSize="xl" fontWeight="bold" color="fg.muted">
|
||||
{t("vs")}
|
||||
|
||||
@@ -143,6 +143,36 @@ function formatUnits(value?: number): string {
|
||||
return `${value.toFixed(1)}u`;
|
||||
}
|
||||
|
||||
function getEngineLabelPalette(label?: string): string {
|
||||
switch ((label || "").toUpperCase()) {
|
||||
case "YUKSEK":
|
||||
return "green";
|
||||
case "ORTA":
|
||||
return "yellow";
|
||||
case "DUSUK":
|
||||
return "orange";
|
||||
case "COK_DUSUK":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
function getEngineLabelText(label?: string): string {
|
||||
switch ((label || "").toUpperCase()) {
|
||||
case "YUKSEK":
|
||||
return "Yüksek";
|
||||
case "ORTA":
|
||||
return "Orta";
|
||||
case "DUSUK":
|
||||
return "Düşük";
|
||||
case "COK_DUSUK":
|
||||
return "Çok Düşük";
|
||||
default:
|
||||
return label || "";
|
||||
}
|
||||
}
|
||||
|
||||
function getRiskPalette(level: string) {
|
||||
switch (level.toUpperCase()) {
|
||||
case "LOW":
|
||||
@@ -731,6 +761,58 @@ function SummaryTable({
|
||||
</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>
|
||||
{item.is_underdog_reference ? (
|
||||
<Badge colorPalette="gray" variant="outline" title="Underdog tarafının model olasılığı (bilgi amaçlı)">
|
||||
Underdog ref.
|
||||
</Badge>
|
||||
) : null}
|
||||
{item.betting_brain?.trap_market_flag ? (
|
||||
<Badge colorPalette="red" variant="subtle" title={`Piyasa aşırı güveniyor (gap ${(item.betting_brain.trap_market_gap || 0) * 100 | 0}pp)`}>
|
||||
Trap
|
||||
</Badge>
|
||||
) : null}
|
||||
{item.betting_brain?.action === "WATCH_NO_VALUE" ? (
|
||||
<Badge colorPalette="orange" variant="subtle" title="Model favoriyle hemfikir ama oran çok düşük">
|
||||
No-value
|
||||
</Badge>
|
||||
) : null}
|
||||
<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="68px" color={item.ev_edge > 0 ? "green.500" : "red.500"} fontWeight="semibold">
|
||||
{item.ev_edge > 0 ? "+" : ""}
|
||||
{formatPercent(item.ev_edge * 100, 1)}
|
||||
</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>
|
||||
@@ -869,7 +951,7 @@ function MarketBoardSection({
|
||||
value={probability * 100}
|
||||
color={
|
||||
entry.pick === outcome ||
|
||||
entry.pick?.toUpperCase() === outcome.toUpperCase()
|
||||
entry.pick?.toUpperCase() === outcome.toUpperCase()
|
||||
? "green.400"
|
||||
: "blue.400"
|
||||
}
|
||||
@@ -980,6 +1062,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
const sport = getPredictionSport(prediction);
|
||||
const isBasketball = sport === "basketball";
|
||||
|
||||
const engineDetail = prediction.engine_breakdown.detail;
|
||||
const engineItems = [
|
||||
{
|
||||
key: "team",
|
||||
@@ -987,6 +1070,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
label: isBasketball ? "Takim Formu" : "Takim Gucu",
|
||||
value: prediction.engine_breakdown.team,
|
||||
color: "blue.400",
|
||||
detail: engineDetail?.team,
|
||||
},
|
||||
{
|
||||
key: "player",
|
||||
@@ -994,6 +1078,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
label: isBasketball ? "Kadro Etkisi" : "Oyuncu Etkisi",
|
||||
value: prediction.engine_breakdown.player,
|
||||
color: "green.400",
|
||||
detail: engineDetail?.player,
|
||||
},
|
||||
{
|
||||
key: "odds",
|
||||
@@ -1002,17 +1087,84 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
value: prediction.engine_breakdown.odds,
|
||||
color: "orange.400",
|
||||
},
|
||||
{
|
||||
key: "odds",
|
||||
icon: LuTrendingUp,
|
||||
label: "Oran Analizi",
|
||||
value: prediction.engine_breakdown.odds,
|
||||
color: "orange.400",
|
||||
detail: engineDetail?.odds,
|
||||
},
|
||||
{
|
||||
key: "referee",
|
||||
icon: LuShieldAlert,
|
||||
label: isBasketball ? "Yardimci Sinyaller" : "Hakem Etkisi",
|
||||
value: prediction.engine_breakdown.referee,
|
||||
color: "purple.400",
|
||||
detail: engineDetail?.referee,
|
||||
},
|
||||
];
|
||||
|
||||
const liveScoreHome = prediction.match_info?.current_score_home;
|
||||
const liveScoreAway = prediction.match_info?.current_score_away;
|
||||
const isLive = Boolean(prediction.match_info?.is_live);
|
||||
const isStale = Boolean(prediction.prediction_freshness?.is_stale_for_live);
|
||||
const contradictions = prediction.match_commentary?.contradictions || [];
|
||||
|
||||
return (
|
||||
<VStack align="stretch" gap={5}>
|
||||
{isLive ? (
|
||||
<Box
|
||||
p={3}
|
||||
bg={useColorModeValue("red.50", "red.950")}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue("red.300", "red.800")}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack gap={2}>
|
||||
<Icon as={LuFlame} color="red.500" />
|
||||
<Text fontWeight="bold" color="red.600">
|
||||
🔴 CANLI
|
||||
</Text>
|
||||
{liveScoreHome != null && liveScoreAway != null ? (
|
||||
<Text fontWeight="semibold">
|
||||
{prediction.match_info.home_team} {liveScoreHome} - {liveScoreAway}{" "}
|
||||
{prediction.match_info.away_team}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
{isStale ? (
|
||||
<Badge colorPalette="orange" variant="solid">
|
||||
Maç öncesi tahmin
|
||||
</Badge>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{contradictions.length ? (
|
||||
<Box
|
||||
p={3}
|
||||
bg={useColorModeValue("yellow.50", "yellow.950")}
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue("yellow.300", "yellow.800")}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<HStack align="start" gap={2}>
|
||||
<Icon as={LuTriangleAlert} color="yellow.600" mt={0.5} />
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontWeight="semibold">Tahmin Çelişkileri</Text>
|
||||
{contradictions.map((text, idx) => (
|
||||
<Text key={idx} fontSize="sm" color="fg.muted">
|
||||
• {text}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Card.Root bg={pageBg} borderColor={borderColor} borderRadius="2xl">
|
||||
<Card.Body gap={5}>
|
||||
<SectionTitle
|
||||
@@ -1114,7 +1266,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
label={uiText("stake-label", "Onerilen Miktar (Stake)")}
|
||||
value={formatUnits(
|
||||
recommendedPick.stake_units ||
|
||||
prediction.bet_advice.suggested_stake_units,
|
||||
prediction.bet_advice.suggested_stake_units,
|
||||
)}
|
||||
helper={uiText(
|
||||
"stake-info",
|
||||
@@ -1171,7 +1323,7 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
</SimpleGrid>
|
||||
|
||||
{prediction.risk.is_surprise_risk ||
|
||||
prediction.risk.warnings?.length ? (
|
||||
prediction.risk.warnings?.length ? (
|
||||
<Box
|
||||
p={4}
|
||||
bg={useColorModeValue("orange.50", "orange.950")}
|
||||
@@ -1204,13 +1356,31 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
{formatPercent(prediction.risk.surprise_score, 0)}
|
||||
</Text>
|
||||
) : null}
|
||||
<ReasonList
|
||||
items={[
|
||||
...(prediction.risk.surprise_reasons || []),
|
||||
...prediction.risk.warnings,
|
||||
]}
|
||||
resolveReason={resolveReason}
|
||||
/>
|
||||
{prediction.risk.surprise_breakdown?.length ? (
|
||||
<VStack align="start" gap={1} mt={1}>
|
||||
{prediction.risk.surprise_breakdown.map((entry) => (
|
||||
<HStack key={entry.code} gap={2}>
|
||||
<Badge
|
||||
colorPalette={entry.points >= 15 ? "red" : entry.points >= 8 ? "orange" : "yellow"}
|
||||
variant="subtle"
|
||||
>
|
||||
+{entry.points.toFixed(0)}
|
||||
</Badge>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{entry.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<ReasonList
|
||||
items={[
|
||||
...(prediction.risk.surprise_reasons || []),
|
||||
...prediction.risk.warnings,
|
||||
]}
|
||||
resolveReason={resolveReason}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
@@ -1245,15 +1415,31 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
{item.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
+{item.value.toFixed(1)}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
{item.detail?.label ? (
|
||||
<Badge
|
||||
colorPalette={getEngineLabelPalette(item.detail.label)}
|
||||
variant="subtle"
|
||||
>
|
||||
{getEngineLabelText(item.detail.label)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
+{item.value.toFixed(1)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</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")} />
|
||||
{item.detail?.interpretation ? (
|
||||
<Text fontSize="xs" color="fg.muted" mt={2}>
|
||||
{item.detail.interpretation}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -1325,6 +1511,36 @@ export default function PredictionCard({ prediction }: PredictionCardProps) {
|
||||
"Butun secenekleri tek tabloda karsilastir.",
|
||||
)}
|
||||
/>
|
||||
{prediction.match_commentary?.headline || prediction.match_commentary?.summary ? (
|
||||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="2xl">
|
||||
<Card.Body gap={3}>
|
||||
<SectionTitle
|
||||
icon={LuBrain}
|
||||
title="Maç Yorumu"
|
||||
info="Modelin maç hakkındaki insan-okunabilir özeti"
|
||||
/>
|
||||
{prediction.match_commentary.headline ? (
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
{prediction.match_commentary.headline}
|
||||
</Text>
|
||||
) : null}
|
||||
{prediction.match_commentary.summary ? (
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{prediction.match_commentary.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
{prediction.match_commentary.notes?.length ? (
|
||||
<VStack align="start" gap={1}>
|
||||
{prediction.match_commentary.notes.map((note, idx) => (
|
||||
<Text key={idx} fontSize="sm">
|
||||
• {note}
|
||||
</Text>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : null}
|
||||
<ScoreCard prediction={prediction} sport={sport} />
|
||||
<MarketBoardSection
|
||||
marketBoard={prediction.market_board}
|
||||
|
||||
@@ -23,7 +23,9 @@ export function Provider(props: ColorModeProviderProps) {
|
||||
</AOSProvider>
|
||||
</PaddleProvider>
|
||||
</ReactQueryProvider>
|
||||
|
||||
</ChakraProvider>
|
||||
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { useToken } from "@chakra-ui/react";
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { useToken } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function TopLoader() {
|
||||
const [color] = useToken("colors", ["primary.500"]);
|
||||
const [color] = useToken('colors', ['primary.500']);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
return <NextTopLoader color={color} showSpinner={false} />;
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return <NextTopLoader color={color || '#319795'} showSpinner={false} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user