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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user