diff --git a/messages/en.json b/messages/en.json index eb462a1..47f81e2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -326,7 +326,9 @@ "siteSettings": "Settings", "messaging": "Messaging", "marketing": "Marketing", - "crm": "CRM" + "crm": "CRM", + "ranking": "Ranking", + "stats": "Dashboard" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index 0e79cfd..5acae1e 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -326,7 +326,9 @@ "siteSettings": "تنظیمات سایت", "messaging": "پیام‌رسانی", "marketing": "بازاریابی", - "crm": "مدیریت مشتریان" + "crm": "مدیریت مشتریان", + "ranking": "رتبه‌بندی", + "stats": "داشبورد" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs index e563d09..f0dbd15 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs @@ -43,6 +43,8 @@ public class TemplateService(ContentDbContext db) "sort_asc" => q.OrderBy(x => x.Sort), "name_asc" => q.OrderBy(x => x.Name), "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) }; @@ -132,6 +134,16 @@ public class TemplateService(ContentDbContext db) await db.SaveChangesAsync(); } + /// Lightweight ranking update — set a template's manual sort weight without a full edit. + 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 GetProjectDetailAsync(Guid id) { var project = await db.Projects diff --git a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs index 65d5af3..9706b9e 100644 --- a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs +++ b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs @@ -40,8 +40,19 @@ public class TemplatesController(TemplateService svc) : ControllerBase await svc.DeleteContainerAsync(id); return NoContent(); } + + // ── Ranking: set manual sort weight (feature / pin) ───────────────────────── + [Authorize(Roles = "Admin")] + [HttpPatch("{id:guid}/sort")] + public async Task SetSort(Guid id, [FromBody] SetSortRequest req) + { + await svc.SetContainerSortAsync(id, req.Sort); + return Ok(new { ok = true }); + } } +public record SetSortRequest(int Sort); + [ApiController] [Route("v1/projects")] public class ProjectsController(TemplateService svc) : ControllerBase diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 89cfd7a..365ec58 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -16,8 +16,10 @@ export default async function AdminLayout({ } const t = await getTranslations("auto.appAdminLayout"); const links: { href: string; label: string }[] = [ + { href: "/admin/stats", label: t("stats") }, { href: "/admin/categories", label: t("categories") }, { href: "/admin/templates", label: t("templates") }, + { href: "/admin/ranking", label: t("ranking") }, { href: "/admin/tags", label: t("tags") }, { href: "/admin/fonts", label: t("fonts") }, { href: "/admin/blogs", label: t("blogs") }, diff --git a/src/app/[locale]/admin/ranking/page.tsx b/src/app/[locale]/admin/ranking/page.tsx new file mode 100644 index 0000000..34a8802 --- /dev/null +++ b/src/app/[locale]/admin/ranking/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { RankingAdmin } from "@/components/admin/RankingAdmin"; + +export default function Page() { + return ; +} diff --git a/src/app/[locale]/admin/stats/page.tsx b/src/app/[locale]/admin/stats/page.tsx new file mode 100644 index 0000000..eee7a42 --- /dev/null +++ b/src/app/[locale]/admin/stats/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { StatsAdmin } from "@/components/admin/StatsAdmin"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/RankingAdmin.tsx b/src/components/admin/RankingAdmin.tsx new file mode 100644 index 0000000..d171fb1 --- /dev/null +++ b/src/components/admin/RankingAdmin.tsx @@ -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([]); + const [sort, setSort] = useState("use_count_desc"); + const [loading, setLoading] = useState(true); + const [draft, setDraft] = useState>({}); + const [msg, setMsg] = useState(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 ( +
+
+
+

رتبه‌بندی قالب‌ها

+

قالب‌ها را بر اساس محبوبیت ببینید و وزن ترتیب دستی را برای «ویژه/پین» تنظیم کنید.

+
+
+ {msg && {msg}} + +
+
+ +
+ + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map((t, i) => ( + + + + + + + + + ))} + +
#قالببازدیداستفادهامتیازترتیب دستی
در حال بارگذاری…
قالبی یافت نشد.
{i + 1}{t.name}{(t.view_count ?? 0).toLocaleString("fa-IR")}{(t.use_count ?? 0).toLocaleString("fa-IR")}{(t.rate_avg ?? 0).toFixed(1)} ({t.rate_count ?? 0}) +
+ setDraft({ ...draft, [t.id]: e.target.value })} /> + +
+
+
+
+ ); +} diff --git a/src/components/admin/StatsAdmin.tsx b/src/components/admin/StatsAdmin.tsx new file mode 100644 index 0000000..aa1c5ff --- /dev/null +++ b/src/components/admin/StatsAdmin.tsx @@ -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 { + 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(null); + const [counts, setCounts] = useState>({}); + 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") => ( +
+
{label}
+
{value}
+
+ ); + + return ( +
+
+

داشبورد آماری

+

نمای کلی از کاربران، درآمد و محتوا.

+
+ + {loading ?

در حال بارگذاری…

: ( + <> +
+ {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"))} +
+
+
جذب کاربر (۱ سال اخیر)
+
+
{(crm?.total_signups ?? 0).toLocaleString("fa-IR")}
ثبت‌نام
+
{(crm?.buyers ?? 0).toLocaleString("fa-IR")}
خریدار
+
{(crm?.conversion_rate ?? 0).toLocaleString("fa-IR")}٪
تبدیل
+
+
+ + )} +
+ ); +}