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
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:
+3
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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") },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RankingAdmin } from "@/components/admin/RankingAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <RankingAdmin />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { StatsAdmin } from "@/components/admin/StatsAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <StatsAdmin />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user