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