feat(content+admin): content ranking + statistics dashboard
Build backend images / build content-svc (push) Failing after 16s
Build backend images / build file-svc (push) Failing after 48s
Build backend images / build gateway (push) Failing after 17s
Build backend images / build identity-svc (push) Failing after 2m12s
Build backend images / build notification-svc (push) Failing after 3m15s
Build backend images / build render-svc (push) Failing after 51s
Build backend images / build studio-svc (push) Failing after 56s

- content-svc: template list gains popularity/rating sort modes (use_count_desc,
  popular, rating_desc); new PATCH /v1/templates/{id}/sort to set manual sort
  weight (feature/pin) without a full edit
- admin /admin/ranking: templates ordered by popularity with views/uses/rating
  and inline manual-sort editor
- admin /admin/stats: overview dashboard (users, revenue, paying customers,
  conversion, templates/categories/campaigns/blogs counts) aggregated from
  existing identity + content endpoints
- nav: Dashboard + Ranking links

Completes the epic: SMS/Email/Templates → Marketing → CRM → Ranking + Stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 22:11:18 +03:30
parent 62a5121ffe
commit 2c961b123b
9 changed files with 220 additions and 2 deletions
+3 -1
View File
@@ -326,7 +326,9 @@
"siteSettings": "Settings", "siteSettings": "Settings",
"messaging": "Messaging", "messaging": "Messaging",
"marketing": "Marketing", "marketing": "Marketing",
"crm": "CRM" "crm": "CRM",
"ranking": "Ranking",
"stats": "Dashboard"
}, },
"appAdminNodesPage": { "appAdminNodesPage": {
"title": "Render Nodes", "title": "Render Nodes",
+3 -1
View File
@@ -326,7 +326,9 @@
"siteSettings": "تنظیمات سایت", "siteSettings": "تنظیمات سایت",
"messaging": "پیام‌رسانی", "messaging": "پیام‌رسانی",
"marketing": "بازاریابی", "marketing": "بازاریابی",
"crm": "مدیریت مشتریان" "crm": "مدیریت مشتریان",
"ranking": "رتبه‌بندی",
"stats": "داشبورد"
}, },
"appAdminNodesPage": { "appAdminNodesPage": {
"title": "نودهای رندر", "title": "نودهای رندر",
@@ -43,6 +43,8 @@ public class TemplateService(ContentDbContext db)
"sort_asc" => q.OrderBy(x => x.Sort), "sort_asc" => q.OrderBy(x => x.Sort),
"name_asc" => q.OrderBy(x => x.Name), "name_asc" => q.OrderBy(x => x.Name),
"view_count_desc" => q.OrderByDescending(x => x.ViewCount), "view_count_desc" => q.OrderByDescending(x => x.ViewCount),
"use_count_desc" or "popular" => q.OrderByDescending(x => x.UseCount).ThenByDescending(x => x.ViewCount),
"rating_desc" => q.OrderByDescending(x => x.RateAvg).ThenByDescending(x => x.RateCount),
_ => q.OrderByDescending(x => x.SortDate) _ => q.OrderByDescending(x => x.SortDate)
}; };
@@ -132,6 +134,16 @@ public class TemplateService(ContentDbContext db)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
/// <summary>Lightweight ranking update — set a template's manual sort weight without a full edit.</summary>
public async Task SetContainerSortAsync(Guid id, int sort)
{
var container = await db.ProjectContainers.FindAsync(id)
?? throw new KeyNotFoundException($"Container {id} not found");
container.Sort = sort;
container.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<ProjectDetailResponse> GetProjectDetailAsync(Guid id) public async Task<ProjectDetailResponse> GetProjectDetailAsync(Guid id)
{ {
var project = await db.Projects var project = await db.Projects
@@ -40,8 +40,19 @@ public class TemplatesController(TemplateService svc) : ControllerBase
await svc.DeleteContainerAsync(id); await svc.DeleteContainerAsync(id);
return NoContent(); return NoContent();
} }
// ── Ranking: set manual sort weight (feature / pin) ─────────────────────────
[Authorize(Roles = "Admin")]
[HttpPatch("{id:guid}/sort")]
public async Task<IActionResult> SetSort(Guid id, [FromBody] SetSortRequest req)
{
await svc.SetContainerSortAsync(id, req.Sort);
return Ok(new { ok = true });
}
} }
public record SetSortRequest(int Sort);
[ApiController] [ApiController]
[Route("v1/projects")] [Route("v1/projects")]
public class ProjectsController(TemplateService svc) : ControllerBase public class ProjectsController(TemplateService svc) : ControllerBase
+2
View File
@@ -16,8 +16,10 @@ export default async function AdminLayout({
} }
const t = await getTranslations("auto.appAdminLayout"); const t = await getTranslations("auto.appAdminLayout");
const links: { href: string; label: string }[] = [ const links: { href: string; label: string }[] = [
{ href: "/admin/stats", label: t("stats") },
{ href: "/admin/categories", label: t("categories") }, { href: "/admin/categories", label: t("categories") },
{ href: "/admin/templates", label: t("templates") }, { href: "/admin/templates", label: t("templates") },
{ href: "/admin/ranking", label: t("ranking") },
{ href: "/admin/tags", label: t("tags") }, { href: "/admin/tags", label: t("tags") },
{ href: "/admin/fonts", label: t("fonts") }, { href: "/admin/fonts", label: t("fonts") },
{ href: "/admin/blogs", label: t("blogs") }, { href: "/admin/blogs", label: t("blogs") },
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { RankingAdmin } from "@/components/admin/RankingAdmin";
export default function Page() {
return <RankingAdmin />;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { StatsAdmin } from "@/components/admin/StatsAdmin";
export default function Page() {
return <StatsAdmin />;
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface Tpl {
id: string; name: string; slug: string;
view_count?: number; use_count?: number; rate_avg?: number; rate_count?: number; sort?: number;
}
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2 py-1 text-sm text-gray-100 outline-none focus:border-indigo-500";
const ghost = "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
const SORTS = [
{ key: "use_count_desc", label: "محبوب‌ترین (استفاده)" },
{ key: "view_count_desc", label: "پربازدیدترین" },
{ key: "rating_desc", label: "بالاترین امتیاز" },
{ key: "sort_asc", label: "ترتیب دستی" },
];
export function RankingAdmin() {
const [rows, setRows] = useState<Tpl[]>([]);
const [sort, setSort] = useState("use_count_desc");
const [loading, setLoading] = useState(true);
const [draft, setDraft] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
const r = await fetch(`/api/admin/resource/templates?sort=${sort}&pageSize=100&isPublished=true`, { cache: "no-store" })
.then((x) => x.json()).catch(() => null);
const list: Tpl[] = r?.data ?? (Array.isArray(r) ? r : []);
setRows(list);
setDraft(Object.fromEntries(list.map((t) => [t.id, String(t.sort ?? 0)])));
setLoading(false);
}, [sort]);
useEffect(() => { load(); }, [load]);
const saveSort = async (id: string) => {
const res = await fetch(`/api/admin/resource/templates/${id}/sort`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sort: Number(draft[id]) || 0 }),
});
setMsg(res.ok ? "ترتیب ذخیره شد ✓" : "خطا");
setTimeout(() => setMsg(null), 2000);
};
return (
<div className="space-y-5" dir="rtl">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-white">رتبهبندی قالبها</h1>
<p className="mt-1 text-sm text-gray-400">قالبها را بر اساس محبوبیت ببینید و وزن ترتیب دستی را برای «ویژه/پین» تنظیم کنید.</p>
</div>
<div className="flex items-center gap-2">
{msg && <span className="text-xs text-gray-400">{msg}</span>}
<select className={inp} value={sort} onChange={(e) => setSort(e.target.value)}>
{SORTS.map((s) => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</div>
</div>
<div className={`${card} overflow-hidden`}>
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2235] text-right text-xs text-gray-500">
<th className="px-4 py-3">#</th><th className="px-4 py-3">قالب</th>
<th className="px-4 py-3">بازدید</th><th className="px-4 py-3">استفاده</th><th className="px-4 py-3">امتیاز</th>
<th className="px-4 py-3">ترتیب دستی</th>
</tr></thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">در حال بارگذاری</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">قالبی یافت نشد.</td></tr>
) : rows.map((t, i) => (
<tr key={t.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
<td className="px-4 py-3 font-mono text-gray-500">{i + 1}</td>
<td className="px-4 py-3 text-gray-200">{t.name}</td>
<td className="px-4 py-3 text-gray-400">{(t.view_count ?? 0).toLocaleString("fa-IR")}</td>
<td className="px-4 py-3 text-emerald-300">{(t.use_count ?? 0).toLocaleString("fa-IR")}</td>
<td className="px-4 py-3 text-amber-300">{(t.rate_avg ?? 0).toFixed(1)} ({t.rate_count ?? 0})</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<input className={`${inp} w-20`} type="number" value={draft[t.id] ?? "0"} onChange={(e) => setDraft({ ...draft, [t.id]: e.target.value })} />
<button className={ghost} onClick={() => saveSort(t.id)}>ذخیره</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
interface Crm { total_signups: number; buyers: number; conversion_rate: number; revenue_minor: number; paying_users_all_time: number }
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120] p-5";
function toman(minor: number) { return (minor / 10).toLocaleString("fa-IR"); }
async function total(path: string): Promise<number> {
const r = await fetch(`/api/admin/resource/${path}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
if (!r) return 0;
if (typeof r?.meta?.total === "number") return r.meta.total;
if (Array.isArray(r?.data)) return r.data.length;
if (Array.isArray(r)) return r.length;
return 0;
}
export function StatsAdmin() {
const [crm, setCrm] = useState<Crm | null>(null);
const [counts, setCounts] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const since = new Date(Date.now() - 365 * 864e5).toISOString().slice(0, 10);
const to = new Date().toISOString().slice(0, 10);
const [c, users, templates, categories, campaigns, blogs] = await Promise.all([
fetch(`/api/admin/resource/admin/crm/analytics?start=${since}&end=${to}`, { cache: "no-store" }).then((x) => x.ok ? x.json() : null).catch(() => null),
total("users?pageSize=1"),
total("templates?pageSize=1"),
total("categories"),
total("campaigns"),
total("blogs?pageSize=1"),
]);
setCrm(c);
setCounts({ users, templates, categories, campaigns, blogs });
setLoading(false);
})();
}, []);
const stat = (label: string, value: string, accent = "text-white") => (
<div className={card}>
<div className="text-xs text-gray-500">{label}</div>
<div className={`mt-2 text-2xl font-bold ${accent}`}>{value}</div>
</div>
);
return (
<div className="space-y-5" dir="rtl">
<div>
<h1 className="text-xl font-semibold text-white">داشبورد آماری</h1>
<p className="mt-1 text-sm text-gray-400">نمای کلی از کاربران، درآمد و محتوا.</p>
</div>
{loading ? <p className="text-sm text-gray-500">در حال بارگذاری</p> : (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{stat("کاربران", (counts.users ?? 0).toLocaleString("fa-IR"))}
{stat("درآمد (۱ سال)", toman(crm?.revenue_minor ?? 0) + " ت", "text-emerald-300")}
{stat("مشتریان پرداخت‌کننده", (crm?.paying_users_all_time ?? 0).toLocaleString("fa-IR"))}
{stat("نرخ تبدیل", (crm?.conversion_rate ?? 0).toLocaleString("fa-IR") + "٪", "text-indigo-300")}
{stat("قالب‌ها", (counts.templates ?? 0).toLocaleString("fa-IR"))}
{stat("دسته‌بندی‌ها", (counts.categories ?? 0).toLocaleString("fa-IR"))}
{stat("کمپین‌ها", (counts.campaigns ?? 0).toLocaleString("fa-IR"))}
{stat("مقالات", (counts.blogs ?? 0).toLocaleString("fa-IR"))}
</div>
<div className={card}>
<div className="text-sm font-semibold text-white">جذب کاربر (۱ سال اخیر)</div>
<div className="mt-3 grid grid-cols-3 gap-3 text-center">
<div><div className="text-xl font-bold text-white">{(crm?.total_signups ?? 0).toLocaleString("fa-IR")}</div><div className="text-xs text-gray-500">ثبتنام</div></div>
<div><div className="text-xl font-bold text-emerald-300">{(crm?.buyers ?? 0).toLocaleString("fa-IR")}</div><div className="text-xs text-gray-500">خریدار</div></div>
<div><div className="text-xl font-bold text-indigo-300">{(crm?.conversion_rate ?? 0).toLocaleString("fa-IR")}٪</div><div className="text-xs text-gray-500">تبدیل</div></div>
</div>
</div>
</>
)}
</div>
);
}