fix(admin-dashboard): improve blog engagement visuals

This commit is contained in:
2026-06-15 17:38:11 +03:30
parent 268dd26d9a
commit 4fb44fcb4c

View File

@@ -30,11 +30,8 @@ import {
Cell,
Line,
LineChart,
Scatter,
ScatterChart,
XAxis,
YAxis,
ZAxis,
} from "recharts";
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
import { Badge } from "@/components/ui/badge";
@@ -436,6 +433,167 @@ function DashboardPointDetailModal({
);
}
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>
);
}
function ValuesTable({
data,
unit,
@@ -716,13 +874,16 @@ 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>
<CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
</CardHeader>
<CardContent>
{!data.length ? (
@@ -732,52 +893,48 @@ function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularit
<ChartViewport minWidth={620}>
<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>
@@ -1033,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>