feat: implement 8 features across full stack

- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
    deterministic tie-breaking (xp DESC, user_id ASC)
  - Mentor chat: md: breakpoints, safe-area-inset-bottom padding
  - Learning progress: server-side progressPct, curriculum expansion safety
  - Physical orders: hard-disabled with 410 Gone
  - Certificate approval: pending/issued flow, admin issue/revoke endpoints,
    certificate_requires_approval setting (migration 0031)
  - Draft/publish: is_published on lessons and chapters, admin publish UI
    (migration 0030)
  - Video panel: static thumbnails (no preload), client-side pagination
  - Rich editor: TipTap + tiptap-markdown for lesson descriptions,
    marked + DOMPurify for SafeMarkdown rendering
This commit is contained in:
ahp
2026-10-18 10:14:22 +03:30
parent 75e2892e36
commit 03610bc3d3
23 changed files with 1641 additions and 474 deletions
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Award,
Flame,
Heart,
Loader2,
@@ -64,6 +65,7 @@ export function LearningPanel() {
const [sort, setSort] = useState<SortKey>("xp");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [unlocking, setUnlocking] = useState(false);
const [issuingCert, setIssuingCert] = useState(false);
const [page, setPage] = useState(1);
useEffect(() => {
@@ -114,6 +116,19 @@ export function LearningPanel() {
}
};
const issueCertificate = async (id: number) => {
setIssuingCert(true);
try {
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
toast.success("گواهینامه با موفقیت صادر شد");
await qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] });
} catch (e) {
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
} finally {
setIssuingCert(false);
}
};
const overview = overviewQ.data?.overview;
const students = studentsQ.data?.items ?? [];
const studentsTotal = studentsQ.data?.total ?? 0;
@@ -369,6 +384,25 @@ export function LearningPanel() {
))}
</div>
{detailQ.data.learning.user.progressPct >= 100 && (
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-3 py-2.5">
<div className="flex items-center gap-2">
<Award className="h-4 w-4 shrink-0 text-primary" />
<p className="text-xs font-bold">دوره تکمیل شده — گواهینامه قابل صدور است</p>
</div>
<Button
size="sm"
variant="default"
disabled={issuingCert}
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
className="gap-1.5"
>
{issuingCert ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
صدور گواهینامه
</Button>
</div>
)}
<section className="space-y-2">
<h3 className="text-sm font-black">دروس</h3>
<div className="max-h-64 space-y-1.5 overflow-y-auto">