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
- 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>
96 lines
4.6 KiB
TypeScript
96 lines
4.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|