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
+24 -44
View File
@@ -13,7 +13,6 @@ import {
Heart,
Lock,
MessageSquare,
Package,
PlayCircle,
Sparkles,
TrendingUp,
@@ -22,7 +21,7 @@ import {
import { SkillTree } from "@/components/skill-tree";
import { Skeleton } from "@/components/ui/skeleton";
import { http, toUserError } from "@/lib/api";
import type { Certificate, CertificatePhysicalOrder, MeetingEvent, PhysicalCertSettings, TreeData, TreeLesson } from "@/lib/types";
import type { Certificate, MeetingEvent, TreeData, TreeLesson } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth-store";
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
@@ -58,8 +57,8 @@ function CapPageInner() {
});
const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []);
const completedCount = allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = allLessons.length > 0 && completedCount === allLessons.length;
const completedCount = data?.completedLessons ?? allLessons.filter((l) => l.progress?.passedQuiz).length;
const curriculumDone = (data?.progressPct ?? 0) >= 100;
useEffect(() => {
const lesson = params.get("lesson");
@@ -70,12 +69,7 @@ function CapPageInner() {
const { data: certData } = useQuery({
queryKey: ["me", "certificate"],
queryFn: () =>
http.get<{
certificate: Certificate | null;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
}>("/api/me/certificate"),
queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"),
enabled: !!user && curriculumDone,
});
@@ -102,20 +96,10 @@ function CapPageInner() {
const firstLocked = all.find((l) => l.locked);
const done = curriculumDone;
const pct = all.length ? Math.round((completedCount / all.length) * 100) : 0;
const pct = Math.round(data?.progressPct ?? 0);
const hearts = clampHearts(me?.hearts ?? user?.hearts ?? MAX_HEARTS);
const lowHearts = !hasInfiniteHearts(user?.role) && hearts > 0 && hearts <= 1;
const nextEvent = (eventsData?.events ?? [])[0];
const physicalOpen =
done &&
certData?.physical?.enabled &&
!!certData.certificate &&
(!certData.physicalOrder || certData.physicalOrder.status === "cancelled") &&
(!certData.certificate.issuedAt ||
Date.now() <
new Date(certData.certificate.issuedAt).getTime() +
(certData.physical.windowDays || 30) * 24 * 60 * 60 * 1000);
const next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done);
return (
@@ -181,33 +165,29 @@ function CapPageInner() {
<div>
<p className="text-sm font-extrabold">دوره را تمام کردید</p>
<p className="text-xs text-muted-foreground">
گواهینامه دیجیتال آماده است
{physicalOpen ? " — می‌توانید نسخه فیزیکی هم درخواست کنید." : "."}
{certData?.certificate?.status === "pending"
? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر می‌شود."
: certData?.certificate?.status === "issued"
? "گواهینامه دیجیتال آماده است."
: "گواهینامه دیجیتال آماده است."}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
const id = certData?.certificate?.publicId;
if (id) router.push(`/c/${id}`);
else {
void http
.post<{ certificate: { publicId: string } }>("/api/me/certificate/issue", {})
.then((r) => {
if (r.certificate?.publicId) router.push(`/c/${r.certificate.publicId}`);
})
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}
}}
>
{certData?.certificate?.status === "issued" && certData.certificate.publicId ? (
<Button onClick={() => router.push(`/c/${certData.certificate!.publicId!}`)}>
<Award className="h-4 w-4" /> مشاهده گواهینامه
</Button>
{physicalOpen && certData?.certificate?.publicId && (
<Button variant="outline" onClick={() => router.push(`/c/${certData.certificate!.publicId}`)}>
<Package className="h-4 w-4" /> نسخه فیزیکی
</Button>
)}
</div>
) : certData?.certificate?.status !== "pending" ? (
<Button
onClick={() => {
void http
.post("/api/me/certificate/issue", {})
.then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] }))
.catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")));
}}
>
<Award className="h-4 w-4" /> دریافت گواهینامه
</Button>
) : null}
</div>
)}