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, Cell,
Line, Line,
LineChart, LineChart,
Scatter,
ScatterChart,
XAxis, XAxis,
YAxis, YAxis,
ZAxis,
} from "recharts"; } from "recharts";
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox"; import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
import { Badge } from "@/components/ui/badge"; 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({ function ValuesTable({
data, data,
unit, unit,
@@ -716,13 +874,16 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
); );
} }
function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) { function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) {
const data = group.top_items; 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>محبوبیت نوشتهها</CardTitle> <CardTitle>محبوبیت نوشتهها</CardTitle>
<CardDescription>لایک در برابر ذخیره؛ اندازه نقطه بر اساس تعداد کامنت</CardDescription> <CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!data.length ? ( {!data.length ? (
@@ -732,52 +893,48 @@ function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularit
<ChartViewport minWidth={620}> <ChartViewport minWidth={620}>
<ChartContainer <ChartContainer
config={{ config={{
saves: { label: "ذخیره", color: PALETTE.cyan },
likes: { label: "لایک", color: PALETTE.rose }, 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 }}> <BarChart data={data} layout="vertical" margin={{ top: 12, right: labelAxisWidth + 16, bottom: 24, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis <XAxis
type="number" type="number"
dataKey="likes" reversed
name="لایک" tickLine={false}
axisLine={false}
tickFormatter={(value) => formatNumberPersian(Number(value))} tickFormatter={(value) => formatNumberPersian(Number(value))}
/> />
<YAxis <YAxis
type="number" dataKey="label"
dataKey="saves" type="category"
name="ذخیره" orientation="right"
tickFormatter={(value) => formatNumberPersian(Number(value))} width={labelAxisWidth}
tickLine={false}
axisLine={false}
tickMargin={10}
tickFormatter={(value) => truncateLabel(String(value), 22)}
/> />
<ZAxis type="number" dataKey="comments" range={[70, 380]} /> <ChartTooltip content={<PostEngagementTooltip />} />
<ChartTooltip <Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
cursor={{ strokeDasharray: "3 3" }} <Bar dataKey="saves" stackId="engagement" fill="var(--color-saves)" radius={[0, 0, 0, 0]} />
content={({ active, payload }) => { <Bar dataKey="comments" stackId="engagement" fill="var(--color-comments)" radius={[8, 8, 8, 8]} />
if (!active || !payload?.length) return null; </BarChart>
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>
</ChartContainer> </ChartContainer>
</ChartViewport> </ChartViewport>
{group.total_count > data.length ? ( {group.total_count > data.length ? (
<p className="mt-2 text-xs text-muted-foreground"> <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">
نمایش {formatNumberPersian(data.length)} نوشته برتر از {formatNumberPersian(group.total_count)} نوشته دارای تعامل. <span>نمودار خلاصه {formatNumberPersian(data.length)} نوشته اول را نشان میدهد.</span>
</p> <Button type="button" size="sm" variant="secondary" className="shrink-0" onClick={() => setDetailsOpen(true)}>
مشاهده همه
</Button>
</div>
) : null} ) : null}
<BlogPostEngagementModal open={detailsOpen} onOpenChange={setDetailsOpen} posts={allPosts} />
</> </>
)} )}
</CardContent> </CardContent>
@@ -1033,7 +1190,7 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
</div> </div>
<div className="grid gap-4 xl:grid-cols-3"> <div className="grid gap-4 xl:grid-cols-3">
<div className="xl:col-span-2"> <div className="xl:col-span-2">
<BlogScatterCard group={data.post_popularity} /> <BlogEngagementCard group={data.post_popularity} />
</div> </div>
<ActivityTrendCard data={data.activity_trend} /> <ActivityTrendCard data={data.activity_trend} />
</div> </div>