Compare commits

..

7 Commits

3 changed files with 604 additions and 243 deletions

View File

@@ -760,6 +760,7 @@ export interface AnalyticsPointSchema {
}
export interface AnalyticsPointGroupSchema {
items: AnalyticsPointSchema[];
top_items: AnalyticsPointSchema[];
other_count: number;
total_count: number;
@@ -797,6 +798,7 @@ export interface AnalyticsPostPopularitySchema {
}
export interface AnalyticsPostPopularityGroupSchema {
items: AnalyticsPostPopularitySchema[];
top_items: AnalyticsPostPopularitySchema[];
other_count: number;
total_count: number;

View File

@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
@@ -11,6 +12,7 @@ import {
BarChart3,
BookOpen,
CalendarDays,
Eraser,
GraduationCap,
Heart,
LibraryBig,
@@ -28,17 +30,16 @@ import {
Cell,
Line,
LineChart,
Scatter,
ScatterChart,
XAxis,
YAxis,
ZAxis,
} from "recharts";
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
@@ -81,6 +82,26 @@ type SectionState = DateRangeState & {
eventId?: string | null;
};
type DashboardTab = "users" | "events" | "blog";
const DASHBOARD_TABS: DashboardTab[] = ["users", "events", "blog"];
function parseTab(value: string | null): DashboardTab {
return DASHBOARD_TABS.includes(value as DashboardTab) ? (value as DashboardTab) : "users";
}
function readDateRange(params: Pick<URLSearchParams, "get">, prefix: "users" | "events" | "blog"): DateRangeState {
return {
from: params.get(`${prefix}_from`) || "",
to: params.get(`${prefix}_to`) || "",
};
}
function setParam(params: URLSearchParams, key: string, value?: string | null) {
if (value) params.set(key, value);
else params.delete(key);
}
function toApiDate(date: DateObject | null) {
if (!date) return "";
const gregorian = date.toDate();
@@ -118,7 +139,7 @@ function truncateLabel(value: string, max = 18) {
}
function chartHeight(count: number, min = 280) {
return Math.max(min, count * 38 + 90);
return Math.max(min, count * 32 + 80);
}
function axisWidth(items: AnalyticsPointSchema[]) {
@@ -130,6 +151,10 @@ function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
return group.top_items;
}
function fullGroupItems(group: AnalyticsPointGroupSchema) {
return group.items?.length ? group.items : group.top_items;
}
function StatCard({
title,
value,
@@ -145,14 +170,14 @@ function StatCard({
}) {
return (
<Card className="overflow-hidden">
<CardContent className="flex items-center justify-between gap-4 p-5">
<CardContent className="flex items-center justify-between gap-3 p-4 sm:gap-4 sm:p-5">
<div className="space-y-2 text-right">
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-black leading-tight tracking-tight">{value}</p>
<p className="text-xl font-black leading-tight tracking-tight sm:text-2xl">{value}</p>
<p className="text-xs leading-6 text-muted-foreground">{description}</p>
</div>
<div className="rounded-2xl p-3" style={{ backgroundColor: `${PALETTE[tone]}22`, color: PALETTE[tone] }}>
<Icon className="h-6 w-6" />
<div className="rounded-2xl p-2.5 sm:p-3" style={{ backgroundColor: `${PALETTE[tone]}22`, color: PALETTE[tone] }}>
<Icon className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
</CardContent>
</Card>
@@ -177,7 +202,7 @@ function SectionLoading() {
function EmptyChart({ label = "داده‌ای برای نمایش وجود ندارد." }: { label?: string }) {
return (
<div className="flex h-[240px] items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
<div className="flex h-[200px] items-center justify-center rounded-xl border border-dashed px-4 text-center text-xs text-muted-foreground sm:h-[240px] sm:text-sm">
{label}
</div>
);
@@ -201,6 +226,7 @@ function DateRangeFilter({
onChange={(next) => onChange({ ...value, from: toApiDate(next instanceof DateObject ? next : null) })}
calendar={persian}
locale={persian_fa}
onOpenPickNewDate={false}
calendarPosition="bottom-right"
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="تاریخ شروع"
@@ -214,6 +240,7 @@ function DateRangeFilter({
onChange={(next) => onChange({ ...value, to: toApiDate(next instanceof DateObject ? next : null) })}
calendar={persian}
locale={persian_fa}
onOpenPickNewDate={false}
calendarPosition="bottom-right"
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="تاریخ پایان"
@@ -221,7 +248,8 @@ function DateRangeFilter({
/>
</div>
<div className="flex items-end">
<Button variant="outline" className="w-full md:w-auto" onClick={onReset}>
<Button variant="destructive" className="w-full gap-2 md:w-auto" onClick={onReset}>
<Eraser className="h-4 w-4" />
پاککردن
</Button>
</div>
@@ -240,21 +268,21 @@ function FilterCard({
}) {
return (
<Card>
<CardHeader>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">{children}</CardContent>
</Card>
);
}
function ChartViewport({ children, minWidth = 560 }: { children: React.ReactNode; minWidth?: number }) {
function ChartViewport({ children, minWidth = 420 }: { children: React.ReactNode; minWidth?: number }) {
return (
<div className="overflow-x-auto overflow-y-hidden pb-2">
<div className="overflow-x-auto overflow-y-hidden pb-2" dir="ltr">
<div style={{ minWidth }}>{children}</div>
</div>
);
@@ -284,13 +312,285 @@ function CustomValueTooltip({
);
}
function HighCardinalityNotice({ group }: { group: AnalyticsPointGroupSchema }) {
function FullResultsAction({
group,
onOpen,
}: {
group: AnalyticsPointGroupSchema;
onOpen: () => void;
}) {
if (group.total_count <= group.top_items.length) return null;
return (
<p className="mt-2 text-xs text-muted-foreground">
نمایش {formatNumberPersian(group.top_items.length)} مورد برتر از {formatNumberPersian(group.total_count)} مورد؛{" "}
{formatNumberPersian(group.other_count)} مورد دیگر در نمودار فشرده نشدهاند.
</p>
<div className="mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>
نمودار خلاصه {formatNumberPersian(group.top_items.length)} مورد اول را نشان میدهد؛ همه{" "}
{formatNumberPersian(group.total_count)} مورد در نمای کامل قابل بررسی است.
</span>
<Button type="button" size="sm" variant="secondary" className="shrink-0" onClick={onOpen}>
مشاهده همه
</Button>
</div>
);
}
function DashboardPointDetailModal({
open,
onOpenChange,
title,
description,
data,
color,
unit,
formatter,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
data: AnalyticsPointSchema[];
color: string;
unit?: string;
formatter?: (value: number) => string;
}) {
const [search, setSearch] = React.useState("");
const [sortBy, setSortBy] = React.useState<"value" | "label">("value");
const filteredData = React.useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
const filtered = normalizedSearch
? data.filter((item) => toPersianDigits(item.label).toLocaleLowerCase("fa-IR").includes(normalizedSearch))
: data;
return [...filtered].sort((a, b) => {
if (sortBy === "label") return String(a.label).localeCompare(String(b.label), "fa");
return Number(b.value) - Number(a.value) || String(a.label).localeCompare(String(b.label), "fa");
});
}, [data, search, sortBy]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>{title}</DialogTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-[1fr_auto_auto]">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="جستجو در عنوان‌ها..."
className="text-right"
/>
<Button type="button" variant={sortBy === "value" ? "default" : "outline"} onClick={() => setSortBy("value")}>
مرتبسازی با مقدار
</Button>
<Button type="button" variant={sortBy === "label" ? "default" : "outline"} onClick={() => setSortBy("label")}>
مرتبسازی الفبایی
</Button>
</div>
{!filteredData.length ? (
<EmptyChart label="موردی با این جستجو پیدا نشد." />
) : (
<>
<ChartViewport minWidth={680}>
<ChartContainer
config={{ value: { label: "مقدار", color } }}
className="w-full"
style={{ height: Math.min(Math.max(filteredData.length * 34 + 100, 360), 780) }}
>
<BarChart
data={filteredData}
layout="vertical"
margin={{ top: 12, right: axisWidth(filteredData) + 16, bottom: 24, left: 20 }}
>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
type="number"
reversed
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => formatter ? formatter(Number(value)) : formatNumberPersian(Number(value))}
/>
<YAxis
dataKey="label"
type="category"
orientation="right"
width={axisWidth(filteredData)}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value), 24)}
/>
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={formatter} />} />
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
</BarChart>
</ChartContainer>
</ChartViewport>
<ValuesTable data={filteredData} unit={unit} formatter={formatter} />
</>
)}
</DialogContent>
</Dialog>
);
}
type PostEngagementDatum = AnalyticsPostPopularitySchema & {
label: string;
score: number;
};
function postScore(post: AnalyticsPostPopularitySchema) {
return Number(post.likes || 0) + Number(post.saves || 0) + Number(post.comments || 0);
}
function toPostEngagementData(posts: AnalyticsPostPopularitySchema[]): PostEngagementDatum[] {
return posts.map((post) => ({
...post,
label: post.title,
score: postScore(post),
}));
}
function PostEngagementTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload?: PostEngagementDatum }>;
}) {
if (!active || !payload?.length || !payload[0].payload) return null;
const item = payload[0].payload;
return (
<div className="min-w-56 rounded-lg border bg-background p-3 text-xs shadow-xl" dir="rtl">
<p className="mb-2 max-w-72 font-semibold">{item.title}</p>
<div className="space-y-1 text-muted-foreground">
<p>لایک: {formatNumberPersian(item.likes)}</p>
<p>ذخیره: {formatNumberPersian(item.saves)}</p>
<p>کامنت: {formatNumberPersian(item.comments)}</p>
<p>تعامل کل: {formatNumberPersian(item.score)}</p>
</div>
</div>
);
}
function BlogPostEngagementModal({
open,
onOpenChange,
posts,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
posts: AnalyticsPostPopularitySchema[];
}) {
const [search, setSearch] = React.useState("");
const [sortBy, setSortBy] = React.useState<"score" | "likes" | "saves" | "comments" | "title">("score");
const data = React.useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
const filtered = normalizedSearch
? posts.filter((post) => post.title.toLocaleLowerCase("fa-IR").includes(normalizedSearch))
: posts;
return toPostEngagementData(filtered).sort((a, b) => {
if (sortBy === "title") return a.title.localeCompare(b.title, "fa");
return Number(b[sortBy]) - Number(a[sortBy]) || a.title.localeCompare(b.title, "fa");
});
}, [posts, search, sortBy]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>محبوبیت نوشتهها</DialogTitle>
<p className="text-sm text-muted-foreground">نمای کامل تعامل نوشتهها با امکان جستجو و مرتبسازی</p>
</DialogHeader>
<div className="grid gap-3 lg:grid-cols-[1fr_repeat(5,auto)]">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="جستجو در عنوان نوشته..."
className="text-right"
/>
{[
["score", "تعامل کل"],
["likes", "لایک"],
["saves", "ذخیره"],
["comments", "کامنت"],
["title", "عنوان"],
].map(([key, label]) => (
<Button
key={key}
type="button"
variant={sortBy === key ? "default" : "outline"}
onClick={() => setSortBy(key as typeof sortBy)}
>
{label}
</Button>
))}
</div>
{!data.length ? (
<EmptyChart label="نوشته‌ای با این جستجو پیدا نشد." />
) : (
<>
<ChartViewport minWidth={760}>
<ChartContainer
config={{
likes: { label: "لایک", color: PALETTE.rose },
saves: { label: "ذخیره", color: PALETTE.cyan },
comments: { label: "کامنت", color: PALETTE.amber },
}}
className="w-full"
style={{ height: Math.min(Math.max(data.length * 34 + 100, 380), 820) }}
>
<BarChart
data={data}
layout="vertical"
margin={{ top: 12, right: axisWidth(data.map((post) => ({ label: post.title, value: post.score }))) + 16, bottom: 24, left: 20 }}
>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis type="number" reversed tickLine={false} axisLine={false} tickFormatter={(value) => formatNumberPersian(Number(value))} />
<YAxis
dataKey="label"
type="category"
orientation="right"
width={axisWidth(data.map((post) => ({ label: post.title, value: post.score })))}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value), 26)}
/>
<ChartTooltip content={<PostEngagementTooltip />} />
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
<Bar dataKey="saves" stackId="engagement" fill="var(--color-saves)" radius={[0, 0, 0, 0]} />
<Bar dataKey="comments" stackId="engagement" fill="var(--color-comments)" radius={[8, 8, 8, 8]} />
</BarChart>
</ChartContainer>
</ChartViewport>
<div className="mt-4 max-h-72 overflow-auto rounded-xl border">
<table className="w-full min-w-[620px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-3 py-2 text-right">نوشته</th>
<th className="px-3 py-2 text-right">لایک</th>
<th className="px-3 py-2 text-right">ذخیره</th>
<th className="px-3 py-2 text-right">کامنت</th>
<th className="px-3 py-2 text-right">تعامل کل</th>
</tr>
</thead>
<tbody>
{data.map((post) => (
<tr key={post.id} className="border-t">
<td className="px-3 py-2 font-medium">{post.title}</td>
<td className="px-3 py-2">{formatNumberPersian(post.likes)}</td>
<td className="px-3 py-2">{formatNumberPersian(post.saves)}</td>
<td className="px-3 py-2">{formatNumberPersian(post.comments)}</td>
<td className="px-3 py-2 font-semibold">{formatNumberPersian(post.score)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -306,7 +606,7 @@ function ValuesTable({
if (!data.length) return null;
return (
<div className="mt-4 max-h-56 overflow-auto rounded-xl border">
<table className="w-full text-sm">
<table className="w-full text-xs sm:text-sm">
<tbody>
{data.map((item, index) => (
<tr key={`${item.label}-${index}`} className="border-b last:border-b-0">
@@ -340,13 +640,15 @@ function HorizontalBarCard({
valueFormatter?: (value: number) => string;
}) {
const data = dataWithOtherNotice(group);
const allData = fullGroupItems(group);
const [detailsOpen, setDetailsOpen] = React.useState(false);
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{!data.length ? (
<EmptyChart />
) : (
@@ -357,15 +659,24 @@ function HorizontalBarCard({
className="w-full"
style={{ height: chartHeight(data.length) }}
>
<BarChart data={data} layout="vertical" margin={{ top: 12, right: 16, bottom: 24, left: 20 }}>
<BarChart data={data} layout="vertical" margin={{ top: 12, right: axisWidth(data) + 10, bottom: 24, left: 20 }}>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis type="number" tickFormatter={(value) => valueFormatter ? valueFormatter(Number(value)) : formatNumberPersian(Number(value))} />
<XAxis
type="number"
reversed
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => valueFormatter ? valueFormatter(Number(value)) : formatNumberPersian(Number(value))}
/>
<YAxis
dataKey="label"
type="category"
orientation="right"
width={axisWidth(data)}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value))}
/>
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
@@ -373,8 +684,18 @@ function HorizontalBarCard({
</BarChart>
</ChartContainer>
</ChartViewport>
<HighCardinalityNotice group={group} />
<FullResultsAction group={group} onOpen={() => setDetailsOpen(true)} />
<ValuesTable data={data} unit={unit} formatter={valueFormatter} />
<DashboardPointDetailModal
open={detailsOpen}
onOpenChange={setDetailsOpen}
title={title}
description={`${description}؛ نمایش کامل ${formatNumberPersian(allData.length)} مورد`}
data={allData}
color={color}
unit={unit}
formatter={valueFormatter}
/>
</>
)}
</CardContent>
@@ -397,29 +718,37 @@ function TrendLineCard({
}) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CardHeader className="p-4 sm:p-6">
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<LineChartIcon className="h-5 w-5 text-primary" />
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{!data.length ? (
<EmptyChart />
) : (
<ChartViewport>
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[300px] w-full">
<LineChart data={data} margin={{ top: 16, right: 18, bottom: 32, left: 26 }}>
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[260px] w-full sm:h-[300px]">
<LineChart data={data} margin={{ top: 16, right: 76, bottom: 32, left: 18 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
minTickGap={34}
tickMargin={10}
tickFormatter={formatJalaliTick}
/>
<YAxis width={76} tickFormatter={(value) => valueFormatter(Number(value))} />
<YAxis
orientation="right"
width={76}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => valueFormatter(Number(value))}
/>
<ChartTooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
@@ -453,23 +782,39 @@ function StatusChartCard({
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{!data.length ? (
<EmptyChart />
) : (
<>
<ChartViewport minWidth={460}>
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[300px] w-full">
<BarChart data={data} margin={{ top: 14, right: 14, bottom: 44, left: 22 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="label" tickLine={false} axisLine={false} tickFormatter={(value) => truncateLabel(String(value), 14)} />
<YAxis width={64} tickFormatter={(value) => formatNumberPersian(Number(value))} />
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
<ChartViewport minWidth={400}>
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[260px] w-full sm:h-[300px]">
<BarChart data={data} layout="vertical" margin={{ top: 14, right: 118, bottom: 28, left: 18 }}>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
type="number"
reversed
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => formatNumberPersian(Number(value))}
/>
<YAxis
dataKey="label"
type="category"
orientation="right"
width={108}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value), 14)}
/>
<ChartTooltip content={<CustomValueTooltip />} />
<Bar dataKey="value" radius={[8, 8, 8, 8]}>
{data.map((_, index) => (
<Cell key={index} fill={STATUS_COLORS[index % STATUS_COLORS.length]} />
))}
@@ -488,11 +833,11 @@ function StatusChartCard({
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
return (
<Card>
<CardHeader>
<CardTitle>روند تعاملات بلاگ</CardTitle>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-base sm:text-lg">روند تعاملات بلاگ</CardTitle>
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{!data.length ? (
<EmptyChart />
) : (
@@ -503,12 +848,19 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
saves: { label: "ذخیره", color: PALETTE.cyan },
comments: { label: "کامنت", color: PALETTE.amber },
}}
className="h-[320px] w-full"
className="h-[280px] w-full sm:h-[320px]"
>
<LineChart data={data} margin={{ top: 16, right: 18, bottom: 32, left: 26 }}>
<LineChart data={data} margin={{ top: 16, right: 64, bottom: 32, left: 18 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickFormatter={formatJalaliTick} minTickGap={34} />
<YAxis width={64} tickFormatter={(value) => formatNumberPersian(Number(value))} />
<XAxis dataKey="date" tickLine={false} axisLine={false} tickFormatter={formatJalaliTick} minTickGap={34} tickMargin={10} />
<YAxis
orientation="right"
width={64}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => formatNumberPersian(Number(value))}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line type="monotone" dataKey="likes" stroke="var(--color-likes)" strokeWidth={3} dot={false} />
<Line type="monotone" dataKey="saves" stroke="var(--color-saves)" strokeWidth={3} dot={false} />
@@ -522,68 +874,67 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
);
}
function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) {
const data = group.top_items;
function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) {
const data = toPostEngagementData(group.top_items);
const allPosts = group.items?.length ? group.items : group.top_items;
const [detailsOpen, setDetailsOpen] = React.useState(false);
const labelAxisWidth = axisWidth(data.map((post) => ({ label: post.title, value: post.score })));
return (
<Card>
<CardHeader>
<CardTitle>محبوبیت نوشتهها</CardTitle>
<CardDescription>لایک در برابر ذخیره؛ اندازه نقطه بر اساس تعداد کامنت</CardDescription>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-base sm:text-lg">محبوبیت نوشتهها</CardTitle>
<CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{!data.length ? (
<EmptyChart />
) : (
<>
<ChartViewport minWidth={620}>
<ChartViewport minWidth={460}>
<ChartContainer
config={{
saves: { label: "ذخیره", color: PALETTE.cyan },
likes: { label: "لایک", color: PALETTE.rose },
saves: { label: "ذخیره", color: PALETTE.cyan },
comments: { label: "کامنت", color: PALETTE.amber },
}}
className="h-[340px] w-full"
className="w-full"
style={{ height: chartHeight(data.length, 320) }}
>
<ScatterChart margin={{ top: 18, right: 18, bottom: 36, left: 30 }}>
<CartesianGrid strokeDasharray="3 3" />
<BarChart data={data} layout="vertical" margin={{ top: 12, right: labelAxisWidth + 16, bottom: 24, left: 20 }}>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis
type="number"
dataKey="likes"
name="لایک"
reversed
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatNumberPersian(Number(value))}
/>
<YAxis
type="number"
dataKey="saves"
name="ذخیره"
tickFormatter={(value) => formatNumberPersian(Number(value))}
dataKey="label"
type="category"
orientation="right"
width={labelAxisWidth}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value), 22)}
/>
<ZAxis type="number" dataKey="comments" range={[70, 380]} />
<ChartTooltip
cursor={{ strokeDasharray: "3 3" }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const item = payload[0].payload as AnalyticsPostPopularitySchema;
return (
<div className="min-w-52 rounded-lg border bg-background p-3 text-xs shadow-xl" dir="rtl">
<p className="mb-2 max-w-72 font-semibold">{item.title}</p>
<div className="space-y-1 text-muted-foreground">
<p>لایک: {formatNumberPersian(item.likes)}</p>
<p>ذخیره: {formatNumberPersian(item.saves)}</p>
<p>کامنت: {formatNumberPersian(item.comments)}</p>
</div>
</div>
);
}}
/>
<Scatter data={data} fill="var(--color-saves)" />
</ScatterChart>
<ChartTooltip content={<PostEngagementTooltip />} />
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
<Bar dataKey="saves" stackId="engagement" fill="var(--color-saves)" radius={[0, 0, 0, 0]} />
<Bar dataKey="comments" stackId="engagement" fill="var(--color-comments)" radius={[8, 8, 8, 8]} />
</BarChart>
</ChartContainer>
</ChartViewport>
{group.total_count > data.length ? (
<p className="mt-2 text-xs text-muted-foreground">
نمایش {formatNumberPersian(data.length)} نوشته برتر از {formatNumberPersian(group.total_count)} نوشته دارای تعامل.
</p>
<div className="mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>نمودار خلاصه {formatNumberPersian(data.length)} نوشته اول را نشان میدهد.</span>
<Button type="button" size="sm" variant="secondary" className="shrink-0" onClick={() => setDetailsOpen(true)}>
مشاهده همه
</Button>
</div>
) : null}
<BlogPostEngagementModal open={detailsOpen} onOpenChange={setDetailsOpen} posts={allPosts} />
</>
)}
</CardContent>
@@ -594,11 +945,11 @@ function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularit
function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] }) {
return (
<Card>
<CardHeader>
<CardTitle>رویدادهای برتر</CardTitle>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-base sm:text-lg">رویدادهای برتر</CardTitle>
<CardDescription>بر اساس شرکتکننده تاییدشده، درآمد و زمان برگزاری</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 p-4 pt-0 sm:p-6 sm:pt-0">
{group.top_items.length ? (
group.top_items.map((event, index) => (
<div key={event.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
@@ -629,11 +980,11 @@ function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] })
function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
return (
<Card>
<CardHeader>
<CardTitle>نوشتههای برتر</CardTitle>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-base sm:text-lg">نوشتههای برتر</CardTitle>
<CardDescription>بر اساس جمع لایک، ذخیره و کامنت</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 p-4 pt-0 sm:p-6 sm:pt-0">
{posts.length ? (
posts.map((post, index) => (
<div key={post.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
@@ -655,17 +1006,22 @@ function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
);
}
function UsersSection() {
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
function UsersSection({
filters,
onFiltersChange,
}: {
filters: DateRangeState;
onFiltersChange: (filters: DateRangeState) => void;
}) {
const query = useQuery({
queryKey: ["admin", "analytics", "users", filters],
queryFn: () => api.getAdminUserAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
});
return (
<div className="space-y-6">
<div className="space-y-6" dir="rtl">
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آن‌ها اعمال می‌شود.">
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
</FilterCard>
{query.isLoading ? <SectionLoading /> : null}
{query.isError ? <SectionError error={query.error} /> : null}
@@ -701,8 +1057,13 @@ function UsersContent({ data }: { data: UserAnalyticsSchema }) {
);
}
function EventsSection() {
const [filters, setFilters] = React.useState<SectionState>({ from: "", to: "", eventId: null });
function EventsSection({
filters,
onFiltersChange,
}: {
filters: SectionState;
onFiltersChange: (filters: SectionState) => void;
}) {
const query = useQuery({
queryKey: ["admin", "analytics", "events", filters],
queryFn: () =>
@@ -713,16 +1074,16 @@ function EventsSection() {
}),
});
const reset = () => setFilters({ from: "", to: "", eventId: null });
const reset = () => onFiltersChange({ from: "", to: "", eventId: null });
return (
<div className="space-y-6">
<FilterCard title="فیلتر رویدادها" description="این فیلتر فقط روی آمار رویداد، ثبت‌نام، درآمد و تنوع شرکت‌کنندگان اعمال می‌شود.">
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_1.2fr_auto]">
<div className="xl:col-span-2">
<div className="grid gap-3 xl:grid-cols-[2fr_1.2fr]">
<div>
<DateRangeFilter
value={filters}
onChange={(next) => setFilters((current) => ({ ...current, ...next }))}
onChange={(next) => onFiltersChange({ ...filters, ...next })}
onReset={reset}
/>
</div>
@@ -730,7 +1091,7 @@ function EventsSection() {
<Label>رویداد</Label>
<AsyncSearchableCombobox
value={filters.eventId ?? null}
onChange={(eventId) => setFilters((current) => ({ ...current, eventId }))}
onChange={(eventId) => onFiltersChange({ ...filters, eventId })}
loadOptions={async ({ search, limit, offset }) => {
const data = await api.getAdminDashboardEventOptions({ search, limit, offset });
return {
@@ -746,11 +1107,6 @@ function EventsSection() {
emptyText="رویدادی پیدا نشد."
/>
</div>
<div className="hidden items-end xl:flex">
<Button variant="outline" onClick={reset}>
پاککردن
</Button>
</div>
</div>
</FilterCard>
{query.isLoading ? <SectionLoading /> : null}
@@ -799,8 +1155,13 @@ function EventsContent({ data }: { data: EventAnalyticsSchema }) {
);
}
function BlogSection() {
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
function BlogSection({
filters,
onFiltersChange,
}: {
filters: DateRangeState;
onFiltersChange: (filters: DateRangeState) => void;
}) {
const query = useQuery({
queryKey: ["admin", "analytics", "blog", filters],
queryFn: () => api.getAdminBlogAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
@@ -809,7 +1170,7 @@ function BlogSection() {
return (
<div className="space-y-6">
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشته‌ها و تعاملات بلاگ اعمال می‌شود و به رویدادها وابسته نیست.">
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
</FilterCard>
{query.isLoading ? <SectionLoading /> : null}
{query.isError ? <SectionError error={query.error} /> : null}
@@ -829,7 +1190,7 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="xl:col-span-2">
<BlogScatterCard group={data.post_popularity} />
<BlogEngagementCard group={data.post_popularity} />
</div>
<ActivityTrendCard data={data.activity_trend} />
</div>
@@ -843,19 +1204,46 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
}
export default function AdminDashboard() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = React.useState<DashboardTab>(() => parseTab(searchParams.get("tab")));
const [usersFilters, setUsersFilters] = React.useState<DateRangeState>(() => readDateRange(searchParams, "users"));
const [eventsFilters, setEventsFilters] = React.useState<SectionState>(() => ({
...readDateRange(searchParams, "events"),
eventId: searchParams.get("event_id"),
}));
const [blogFilters, setBlogFilters] = React.useState<DateRangeState>(() => readDateRange(searchParams, "blog"));
React.useEffect(() => {
const params = new URLSearchParams();
params.set("tab", activeTab);
setParam(params, "users_from", usersFilters.from);
setParam(params, "users_to", usersFilters.to);
setParam(params, "events_from", eventsFilters.from);
setParam(params, "events_to", eventsFilters.to);
setParam(params, "event_id", eventsFilters.eventId);
setParam(params, "blog_from", blogFilters.from);
setParam(params, "blog_to", blogFilters.to);
const nextSearch = params.toString();
const currentSearch = searchParams.toString();
if (nextSearch !== currentSearch) {
router.replace(`${pathname}?${nextSearch}`, { scroll: false });
}
}, [activeTab, blogFilters, eventsFilters, pathname, router, searchParams, usersFilters]);
return (
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl font-black tracking-tight">داشبورد دستاوردها</h2>
<p className="mt-1 text-sm text-muted-foreground">
گزارشهای جداگانه برای کاربران، رویدادها و بلاگ با فیلترهای مستقل و خوانا
</p>
</div>
</div>
<Tabs defaultValue="users" className="space-y-6">
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl p-1 sm:w-fit">
<Tabs dir="rtl" value={activeTab} onValueChange={(value) => setActiveTab(parseTab(value))} className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-black tracking-tight sm:text-2xl">داشبورد دستاوردها</h2>
<p className="mt-1 text-xs leading-6 text-muted-foreground sm:text-sm">
گزارشهای جداگانه برای کاربران، رویدادها و بلاگ با فیلترهای مستقل و خوانا
</p>
</div>
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl p-1 lg:w-fit">
<TabsTrigger value="users" className="gap-2 rounded-xl py-2">
<UsersRound className="h-4 w-4" />
کاربران
@@ -868,15 +1256,16 @@ export default function AdminDashboard() {
<Tags className="h-4 w-4" />
بلاگ
</TabsTrigger>
</TabsList>
</TabsList>
</div>
<TabsContent value="users">
<UsersSection />
<UsersSection filters={usersFilters} onFiltersChange={setUsersFilters} />
</TabsContent>
<TabsContent value="events">
<EventsSection />
<EventsSection filters={eventsFilters} onFiltersChange={setEventsFilters} />
</TabsContent>
<TabsContent value="blog">
<BlogSection />
<BlogSection filters={blogFilters} onFiltersChange={setBlogFilters} />
</TabsContent>
</Tabs>
</div>

View File

@@ -1,17 +1,15 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import {
Building2,
CalendarDays,
ChevronDown,
FileText,
FolderTree,
GraduationCap,
LayoutDashboard,
PanelRightClose,
PanelRightOpen,
Menu,
ShieldCheck,
Tags,
TicketPercent,
@@ -20,7 +18,7 @@ import {
import { Navigate, NavLink, useLocation } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
const navGroups = [
@@ -65,31 +63,11 @@ type NavItem = (typeof navGroups)[number]["items"][number];
export default function AdminLayout({ children }: { children: ReactNode }) {
const location = useLocation();
const { user, isAuthenticated, loading } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
dashboard: true,
users: true,
events: true,
blog: true,
});
const canAccessAdmin = useMemo(
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
);
useEffect(() => {
const saved = window.localStorage.getItem("admin-sidebar-collapsed");
if (saved) setCollapsed(saved === "true");
}, []);
const toggleCollapsed = () => {
setCollapsed((current) => {
const next = !current;
window.localStorage.setItem("admin-sidebar-collapsed", String(next));
return next;
});
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
@@ -112,7 +90,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
const visibleGroups = navGroups
.map((group) => ({ ...group, items: group.items.filter(canSeeItem) }))
.filter((group) => group.items.length > 0);
const visibleNavItems = visibleGroups.flatMap((group) => group.items);
const isItemActive = (to: string) => {
if (location.pathname === to) return true;
@@ -129,108 +106,101 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<div className="min-h-screen bg-muted/15" dir="rtl">
<div className="flex min-h-screen">
<aside
className={cn(
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out lg:flex lg:flex-col",
collapsed ? "w-20" : "w-72",
)}
className="sticky top-0 hidden h-screen w-72 shrink-0 border-l bg-background/95 shadow-sm backdrop-blur lg:flex lg:flex-col"
>
<div className="flex items-center justify-between gap-2 border-b p-4">
{!collapsed ? (
<div className="text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
</div>
) : null}
<Button variant="ghost" size="icon" onClick={toggleCollapsed} aria-label="باز و بسته کردن منوی مدیریت">
{collapsed ? <PanelRightOpen className="h-5 w-5" /> : <PanelRightClose className="h-5 w-5" />}
</Button>
<div className="border-b p-4 text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
</div>
<nav className="flex-1 space-y-3 p-3">
{visibleGroups.map((group) => (
<Collapsible
key={group.key}
open={collapsed ? true : openGroups[group.key]}
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.key]: open }))}
>
{!collapsed ? (
<CollapsibleTrigger className="mb-1 flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-semibold text-muted-foreground hover:bg-muted/70">
<span>{group.label}</span>
<ChevronDown
className={cn("h-4 w-4 transition-transform", openGroups[group.key] ? "rotate-180" : "")}
/>
</CollapsibleTrigger>
) : null}
<CollapsibleContent className="space-y-2">
{group.items.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
title={collapsed ? item.label : undefined}
className={cn(
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
collapsed ? "justify-center" : "justify-start",
active
? "bg-primary text-primary-foreground shadow"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-5 w-5 shrink-0" />
{!collapsed ? <span className="font-medium">{item.label}</span> : null}
</NavLink>
);
})}
</CollapsibleContent>
</Collapsible>
<div key={group.key} className="space-y-2">
<p className="px-3 py-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
{group.items.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
className={cn(
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
active
? "bg-primary text-primary-foreground shadow"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="font-medium">{item.label}</span>
</NavLink>
);
})}
</div>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">
<div className="border-b bg-background/90 lg:hidden">
<div className="px-4 py-3 text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 rounded-2xl">
<Menu className="h-4 w-4" />
منو
</Button>
</SheetTrigger>
<SheetContent
side="bottom"
className="max-h-[82vh] overflow-y-auto rounded-t-[2rem] border-t p-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]"
dir="rtl"
>
<SheetHeader className="mt-6 text-right">
<SheetTitle>بخشهای پنل مدیریت</SheetTitle>
</SheetHeader>
<nav className="mt-5 space-y-5">
{visibleGroups.map((group) => (
<div key={group.key} className="space-y-2">
<p className="px-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
<div className="grid gap-2 sm:grid-cols-2">
{group.items.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<SheetClose asChild key={item.to}>
<NavLink
to={item.to}
className={cn(
"flex items-center gap-3 rounded-2xl border px-3 py-3 text-sm transition",
active
? "border-primary bg-primary text-primary-foreground shadow"
: "bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
)}
aria-current={active ? "page" : undefined}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="font-medium">{item.label}</span>
</NavLink>
</SheetClose>
);
})}
</div>
</div>
))}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
<div className="container mx-auto min-w-0 px-3 pb-8 pt-4 sm:px-4 lg:py-6">
{children}
</div>
</div>
</div>
<div
className="fixed inset-x-0 z-50 px-4 lg:hidden"
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
>
<nav
aria-label="Admin mobile navigation"
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
dir="rtl"
>
{visibleNavItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[10px] font-medium transition-all",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
)}
aria-current={active ? "page" : undefined}
>
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
<span className="max-w-full truncate">{item.label}</span>
</NavLink>
);
})}
</nav>
</div>
</div>
);
}