fix(admin-dashboard): improve blog engagement visuals
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user