Files
flatrender/src/components/admin/RankingAdmin.tsx
T
soroush.asadi 2c961b123b
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
feat(content+admin): content ranking + statistics dashboard
- 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>
2026-06-02 22:11:18 +03:30

96 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}