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