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
+6 -156
View File
@@ -1,45 +1,23 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { Loader2, Package } from "lucide-react";
import { Loader2 } from "lucide-react";
import { CertificateCard } from "@/components/certificate-card";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types";
import { toast } from "@/components/providers";
import { formatFaNumber } from "@/lib/utils";
import { clampPhoneInput, normalizePhone, PHONE_INPUT_MAX_LEN } from "@/lib/validation";
import { useEffect, useState } from "react";
import type { Certificate } from "@/lib/types";
type PublicCertResponse = {
certificate: { publicId: string; fullName: string; issuedAt: string };
course: string;
};
const STATUS_LABEL: Record<string, string> = {
requested: "درخواست‌شده — منتظر تأیید پرداخت",
paid: "پرداخت تأیید شد — در صف ارسال",
shipped: "ارسال شد",
cancelled: "لغو شده",
};
export default function CertificatePage() {
const params = useParams<{ id: string }>();
const publicId = params?.id ?? "";
const authUser = useAuth((s) => s.user);
const qc = useQueryClient();
const [requesting, setRequesting] = useState(false);
const [form, setForm] = useState({
recipientName: "",
phone: "",
address: "",
city: "",
postalCode: "",
});
const { data, isLoading, error } = useQuery({
queryKey: ["certificate", publicId],
@@ -50,42 +28,12 @@ export default function CertificatePage() {
const { data: mine } = 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: !!authUser,
});
const isOwner = !!authUser && mine?.certificate?.publicId === publicId;
useEffect(() => {
if (!isOwner || !authUser) return;
setForm((f) => ({
...f,
recipientName: f.recipientName || authUser.name || "",
phone: f.phone || authUser.phone || "",
}));
}, [isOwner, authUser]);
const requestPhysical = async () => {
setRequesting(true);
try {
await http.post("/api/me/certificate/physical", {
...form,
phone: normalizePhone(form.phone),
});
toast.success("درخواست ثبت شد — پس از واریز با ادمین هماهنگ کنید");
qc.invalidateQueries({ queryKey: ["me", "certificate"] });
} catch (e) {
toast.error(toUserError(e));
} finally {
setRequesting(false);
}
};
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
@@ -110,17 +58,6 @@ export default function CertificatePage() {
}
const cert = data.certificate;
const order = isOwner ? mine?.physicalOrder : null;
const physical = isOwner ? mine?.physical : null;
const windowEndsAt = mine?.certificate?.issuedAt
? new Date(mine.certificate.issuedAt).getTime() + (physical?.windowDays ?? 30) * 86400000
: 0;
const windowStillOpen = !windowEndsAt || Date.now() < windowEndsAt;
const windowOpen =
physical?.enabled &&
windowStillOpen &&
(!order || order.status === "cancelled") &&
!!mine?.certificate;
return (
<CertificateCard
@@ -130,94 +67,7 @@ export default function CertificatePage() {
issuedAt: cert.issuedAt,
course: data.course,
}}
showOwnerLinks={!!authUser}
>
{isOwner && physical?.enabled && (
<div className="mt-8 space-y-3 border-t border-border pt-6">
<p className="flex items-center gap-2 text-sm font-bold">
<Package className="h-4 w-4" /> نسخه فیزیکی
</p>
<p className="text-xs text-muted-foreground">
قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "}
{formatFaNumber(physical.windowDays)} روز از صدور
{windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}.
پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش به‌روز شود.
</p>
{order && order.status !== "cancelled" ? (
<div className="space-y-2 rounded-xl border border-border bg-muted/30 p-3 text-sm">
<p>
وضعیت: <span className="font-medium">{STATUS_LABEL[order.status] ?? order.status}</span>
</p>
{order.trackingCode && (
<p className="font-mono text-xs" dir="ltr">
کد پیگیری: {order.trackingCode}
</p>
)}
{order.city && (
<p className="text-xs text-muted-foreground">
ارسال به {order.recipientName || "—"} · {order.city}
</p>
)}
{order.note && <p className="text-xs text-muted-foreground">یادداشت ادمین: {order.note}</p>}
<Button
size="sm"
variant="outline"
onClick={() =>
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true } }))
}
>
هماهنگی با تیم
</Button>
</div>
) : windowOpen ? (
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-2">
<Input
placeholder="نام گیرنده"
value={form.recipientName}
onChange={(e) => setForm((f) => ({ ...f, recipientName: e.target.value }))}
/>
<Input
placeholder="موبایل"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: clampPhoneInput(e.target.value) }))}
dir="ltr"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
className="text-left"
/>
<Input
placeholder="شهر"
value={form.city}
onChange={(e) => setForm((f) => ({ ...f, city: e.target.value }))}
/>
<Input
placeholder="کد پستی"
value={form.postalCode}
onChange={(e) => setForm((f) => ({ ...f, postalCode: e.target.value }))}
dir="ltr"
/>
</div>
<textarea
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="آدرس کامل پستی"
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
/>
<Button
size="sm"
disabled={requesting || !form.address.trim() || !form.city.trim()}
onClick={() => void requestPhysical()}
>
{requesting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Package className="h-3.5 w-3.5" />}
ثبت درخواست نسخه فیزیکی
</Button>
</div>
) : !windowStillOpen ? (
<p className="text-sm text-muted-foreground">مهلت درخواست نسخه فیزیکی به پایان رسیده است.</p>
) : null}
</div>
)}
</CertificateCard>
showOwnerLinks={isOwner}
/>
);
}
+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>
)}
+48 -11
View File
@@ -64,6 +64,7 @@ import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { RichEditor } from "@/components/ui/rich-editor";
import {
Dialog,
DialogContent,
@@ -751,11 +752,31 @@ function CurriculumPanel() {
}
};
const publishLesson = async (l: Lesson, publish: boolean) => {
try {
await http.post(`/api/admin/lessons/${l.id}/publish`, { publish });
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
toast.success(publish ? "درس منتشر شد" : "درس به پیش‌نویس برگشت");
} catch (e) {
toast.error(toUserError(e));
}
};
const publishChapter = async (ch: Chapter, publish: boolean) => {
try {
await http.post(`/api/admin/chapters/${ch.id}/publish`, { publish });
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
toast.success(publish ? "فصل منتشر شد" : "فصل به پیش‌نویس برگشت");
} catch (e) {
toast.error(toUserError(e));
}
};
const newLesson = (chapterId: number) =>
setLessonModal({
id: 0, chapterId, title: "", description: "", videoKey: "",
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true,
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false,
});
const duplicateLesson = async (l: Lesson) => {
@@ -810,7 +831,7 @@ function CurriculumPanel() {
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
</p>
</div>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1, isPublished: false })}>
<Plus className="h-4 w-4" /> فصل جدید
</Button>
</CardHeader>
@@ -830,8 +851,8 @@ function CurriculumPanel() {
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
</div>
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو
<Badge variant={ch.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{ch.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
<GroupNav
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
@@ -840,6 +861,15 @@ function CurriculumPanel() {
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
<Pencil className="h-3 w-3" /> فصل
</Button>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
onClick={() => publishChapter(ch, !ch.isPublished)}
title={ch.isPublished ? "برگشت به پیش‌نویس" : "انتشار فصل"}
>
{ch.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
<Plus className="h-3.5 w-3.5" /> درس
</Button>
@@ -895,8 +925,8 @@ function CurriculumPanel() {
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
</div>
</div>
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]">
{l.isActive ? "فعال" : "پیش‌نویس"}
<Badge variant={l.isPublished ? "success" : "secondary"} className="shrink-0 px-1.5 text-[10px]">
{l.isPublished ? "منتشر" : "پیش‌نویس"}
</Badge>
<div className="flex shrink-0 gap-0.5">
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
@@ -906,6 +936,15 @@ function CurriculumPanel() {
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-1.5 text-[10px]"
title={l.isPublished ? "برگشت به پیش‌نویس" : "انتشار درس"}
onClick={() => publishLesson(l, !l.isPublished)}
>
{l.isPublished ? "برگشت" : "انتشار"}
</Button>
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -1056,7 +1095,7 @@ function LessonDialog({
onClose: () => void;
onSave: (l: Lesson) => void;
}) {
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true });
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true, isPublished: false });
useEffect(() => {
if (lesson) setDraft(lesson);
}, [lesson]);
@@ -1125,12 +1164,10 @@ function LessonDialog({
</div>
<div className="space-y-1.5">
<Label>توضیحات</Label>
<textarea
<RichEditor
value={draft.description}
onChange={(e) => set({ description: e.target.value })}
rows={3}
onChange={(v) => set({ description: v })}
placeholder="چرا این درس مهم است و چه چیزی می‌آموزد؟"
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="space-y-1.5">
@@ -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">
+42 -51
View File
@@ -5,6 +5,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
ChevronLeft,
ChevronRight,
Eye,
Film,
Link2,
@@ -32,7 +34,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { cn, formatFaNumber } from "@/lib/utils";
import { toast } from "@/components/providers";
import { Skeleton } from "@/components/ui/skeleton";
@@ -59,59 +61,19 @@ function VideoThumb({
className?: string;
onOpen?: () => void;
}) {
const [url, setUrl] = useState<string | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let cancelled = false;
setState("loading");
setUrl(null);
signedMediaUrl(videoKey)
.then((u) => {
if (!cancelled) {
setUrl(u);
setState("ready");
}
})
.catch(() => {
if (!cancelled) setState("error");
});
return () => {
cancelled = true;
};
}, [videoKey]);
return (
<button
type="button"
onClick={onOpen}
className={cn(
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
className
className,
)}
title="پیش‌نمایش"
>
{state === "loading" && (
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
)}
{state === "error" && (
<span className="absolute inset-0 grid place-items-center gap-1 text-muted-foreground">
<Film className="h-5 w-5" />
<span className="text-[10px] font-bold">پیش‌نمایش نیست</span>
</span>
)}
{url && state === "ready" && (
<video
src={url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover"
onError={() => setState("error")}
/>
)}
<span className="absolute inset-0 grid place-items-center text-muted-foreground/30">
<Film className="h-10 w-10" />
</span>
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
<Play className="h-4 w-4 fill-current" />
@@ -169,7 +131,7 @@ function VideoPreviewDialog({
</div>
)}
{url && (
<video src={url} controls playsInline className="max-h-[70vh] w-full bg-black" autoPlay />
<video src={url} controls playsInline preload="none" className="max-h-[70vh] w-full bg-black" />
)}
</div>
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
@@ -182,12 +144,15 @@ function VideoPreviewDialog({
);
}
const PAGE_SIZE = 24;
export function VideosPanel() {
const qc = useQueryClient();
const { confirm, dialog } = useConfirm();
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [attachKey, setAttachKey] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState<string | null>(null);
@@ -230,10 +195,13 @@ export function VideosPanel() {
const filtered = useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return videos;
return videos.filter((v) => v.key.toLowerCase().includes(term));
return term ? videos.filter((v) => v.key.toLowerCase().includes(term)) : videos;
}, [videos, q]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const pick = (file: File | undefined) => {
if (!file) return;
const invalid = validateVideoFile(file);
@@ -333,14 +301,14 @@ export function VideosPanel() {
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{videos.length} فایل · روی تصویر بزنید تا پخش شود
{videos.length} فایل · برای پخش روی دکمه کلیک کنید
</p>
</div>
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={(e) => { setQ(e.target.value); setPage(1); }}
placeholder="جستجوی نام فایل…"
className="h-10 ps-9"
/>
@@ -355,7 +323,7 @@ export function VideosPanel() {
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((v) => {
{paginated.map((v) => {
const used = usedByKey.get(v.key) ?? [];
return (
<div
@@ -431,6 +399,29 @@ export function VideosPanel() {
{q ? "نتیجه‌ای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
</p>
)}
{totalPages > 1 && (
<div className="col-span-full flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
disabled={safePage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">
صفحه {formatFaNumber(safePage)} از {formatFaNumber(totalPages)}
</span>
<Button
size="sm"
variant="outline"
disabled={safePage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
+26 -2
View File
@@ -3,10 +3,10 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Crown, Sparkles, Timer, Trophy } from "lucide-react";
import { Crown, Medal, Sparkles, Timer, Trophy } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import type { LeaderboardData, LeaderboardEntry } from "@/lib/types";
import type { LeaderboardData, LeaderboardEntry, LeaderboardTier } from "@/lib/types";
import { cn, formatFaNumber } from "@/lib/utils";
import { Panel } from "@/components/panel";
import { Skeleton } from "@/components/ui/skeleton";
@@ -21,6 +21,28 @@ const medalClasses = {
3: "bg-accent text-accent-foreground shadow-offset-sm",
};
const tierConfig: Record<LeaderboardTier, { label: string; className: string }> = {
champion: { label: "قهرمان", className: "bg-gold/20 text-gold border-gold/30" },
gold: { label: "طلا", className: "bg-gold/15 text-gold border-gold/25" },
silver: { label: "نقره", className: "bg-secondary/30 text-secondary-foreground border-secondary/40" },
bronze: { label: "برنز", className: "bg-accent/20 text-accent-foreground border-accent/30" },
};
function TierBadge({ tier }: { tier: LeaderboardTier }) {
const cfg = tierConfig[tier];
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold",
cfg.className,
)}
>
<Medal className="h-2.5 w-2.5" />
{cfg.label}
</span>
);
}
export function LeaderboardPanel() {
const user = useAuth((s) => s.user);
const { data, isLoading, isError, error, refetch } = useQuery({
@@ -89,6 +111,7 @@ export function LeaderboardPanel() {
رتبه {fa(me.rank)} شمایید
</span>
)}
{me && me.tier && <TierBadge tier={me.tier} />}
</div>
<div className="relative overflow-hidden rounded-2xl border border-gold/25 bg-gradient-to-l from-gold/10 via-card to-primary/5 px-5 py-6 sm:px-8">
@@ -134,6 +157,7 @@ export function LeaderboardPanel() {
شما
</span>
)}
{e.tier && <TierBadge tier={e.tier} />}
<span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز
</span>
+2 -2
View File
@@ -1944,7 +1944,7 @@ ws.onmessage = (ev) => {
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 24, scale: 0.96 }}
transition={softSpring}
className="fixed bottom-24 start-3 end-3 z-[66] flex h-[min(640px,calc(100svh-10rem))] w-auto flex-col overflow-hidden rounded-[26px] border border-white/60 bg-white/55 shadow-[0_24px_70px_-18px_rgba(42,39,69,0.4)] ring-1 ring-black/5 backdrop-blur-2xl sm:bottom-6 sm:start-5 sm:end-auto sm:h-[min(720px,calc(100svh-3rem))] sm:w-[420px] dark:border-white/15 dark:bg-[#14172b]/60 dark:ring-white/10 xl:start-8 xl:w-[440px]"
className="fixed bottom-24 start-3 end-3 z-[66] flex h-[min(640px,calc(100svh-10rem))] w-auto flex-col overflow-hidden rounded-[26px] border border-white/60 bg-white/55 shadow-[0_24px_70px_-18px_rgba(42,39,69,0.4)] ring-1 ring-black/5 backdrop-blur-2xl sm:bottom-6 sm:start-5 sm:end-auto sm:h-[min(720px,calc(100svh-3rem))] sm:w-[420px] md:w-[440px] dark:border-white/15 dark:bg-[#14172b]/60 dark:ring-white/10 xl:start-8 xl:w-[460px]"
>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-l from-primary via-accent to-gold" />
<div aria-hidden className="pointer-events-none absolute -end-16 -top-16 h-40 w-40 rounded-full bg-accent/25 blur-3xl" />
@@ -3010,7 +3010,7 @@ ws.onmessage = (ev) => {
</div>
)}
<div className="relative z-20 mx-2 mb-2 shrink-0 pt-1">
<div className="relative z-20 mx-2 mb-2 shrink-0 pb-[env(safe-area-inset-bottom)] pt-1">
{recording && (
<div className="mb-2 flex items-center gap-2 rounded-xl bg-destructive/10 px-3 py-2">
<div className="flex h-3.5 min-w-9 items-center justify-center gap-[2px]">
@@ -3,18 +3,17 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Award, Copy, ExternalLink, Loader2, Lock, Package } from "lucide-react";
import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react";
import { http, toUserError } from "@/lib/api";
import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types";
import type { Certificate, CertificatePhysicalOrder } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/providers";
import { cn, formatFaNumber } from "@/lib/utils";
import { cn } from "@/lib/utils";
type MineCertificateResponse = {
certificate: Certificate | null;
eligible: boolean;
physicalOrder: CertificatePhysicalOrder | null;
physical: PhysicalCertSettings;
};
const ORDER_LABEL: Record<string, string> = {
@@ -37,7 +36,6 @@ export function ProfileCertificateSlot() {
const cert = data?.certificate ?? null;
const eligible = !!data?.eligible;
const physical = data?.physical;
const order = data?.physicalOrder;
// Auto-issue once when the student becomes eligible (same path as /cap).
@@ -83,14 +81,6 @@ export function ProfileCertificateSlot() {
}
};
const physicalOpen =
!!physical?.enabled &&
!!cert &&
(!order || order.status === "cancelled") &&
(!cert.issuedAt ||
Date.now() <
new Date(cert.issuedAt).getTime() + (physical.windowDays || 30) * 24 * 60 * 60 * 1000);
return (
<section
aria-label="جایگاه گواهینامه"
@@ -118,7 +108,17 @@ export function ProfileCertificateSlot() {
<Loader2 className="h-3.5 w-3.5 animate-spin" />
در حال بارگذاری…
</div>
) : cert ? (
) : cert?.status === "pending" ? (
<div className="flex items-start gap-2 rounded-lg border border-border/70 bg-background/60 px-3 py-2.5">
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<div>
<p className="text-[11px] font-bold text-foreground">در انتظار تأیید</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر می‌شود.
</p>
</div>
</div>
) : cert?.status === "issued" && cert.publicId ? (
<div className="space-y-2.5">
<div className="rounded-lg border border-primary/25 bg-primary/5 px-3 py-2.5">
<p className="text-sm font-bold text-foreground">{cert.fullName}</p>
@@ -129,12 +129,6 @@ export function ProfileCertificateSlot() {
{cert.publicId}
</p>
</div>
{order && order.status !== "cancelled" ? (
<p className="text-[11px] font-medium text-muted-foreground">
{ORDER_LABEL[order.status] ?? order.status}
{order.trackingCode ? ` · کد پیگیری ${order.trackingCode}` : ""}
</p>
) : null}
<div className="flex flex-wrap gap-1.5">
<Button type="button" size="sm" className="h-8 flex-1" onClick={openCert}>
<ExternalLink className="h-3.5 w-3.5" />
@@ -144,12 +138,6 @@ export function ProfileCertificateSlot() {
<Copy className="h-3.5 w-3.5" />
کپی لینک
</Button>
{physicalOpen ? (
<Button type="button" size="sm" variant="outline" className="h-8" onClick={openCert}>
<Package className="h-3.5 w-3.5" />
فیزیکی
</Button>
) : null}
</div>
</div>
) : eligible ? (
@@ -172,10 +160,7 @@ export function ProfileCertificateSlot() {
<div>
<p className="text-[11px] font-bold text-foreground">هنوز صادر نشده</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground">
با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود
{physical?.enabled
? `؛ در صورت فعال بودن، نسخه فیزیکی (پرداخت آفلاین) تا ${formatFaNumber(physical.windowDays)} روز پس از صدور قابل درخواست است.`
: "."}
با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود.
</p>
</div>
</div>
+40 -50
View File
@@ -1,9 +1,8 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
/** Renders a small markdown subset. Raw HTML is escaped by React text nodes. */
export function SafeMarkdown({
text,
className,
@@ -11,56 +10,47 @@ export function SafeMarkdown({
}: {
text: string;
className?: string;
/** When set, only the first block is shown, clamped to this many lines. */
lines?: 2 | 3;
}) {
const blocks = text.replace(/\r\n/g, "\n").split(/\n{2,}/);
const shown = lines ? blocks.slice(0, 1) : blocks;
const [html, setHtml] = useState("");
useEffect(() => {
if (!text) { setHtml(""); return; }
void (async () => {
const { marked } = await import("marked");
const DOMPurify = (await import("dompurify")).default;
const raw = await marked(text);
setHtml(DOMPurify.sanitize(raw));
})();
}, [text]);
if (!html) {
return (
<div
dir="auto"
className={cn(
"break-words text-sm [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
className,
)}
>
{text}
</div>
);
}
return (
<div className={className}>
{shown.map((block, i) => (
<p
key={i}
dir="auto"
className={cn(
"break-words [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
!lines && "whitespace-pre-wrap",
)}
>
{inline(block)}
</p>
))}
</div>
<div
dir="auto"
className={cn(
"prose prose-sm max-w-none break-words [unicode-bidi:plaintext]",
lines === 2 && "line-clamp-2",
lines === 3 && "line-clamp-3",
className,
)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
function inline(src: string): ReactNode[] {
const re = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|`([^`]+)`/g;
const nodes: ReactNode[] = [];
let last = 0;
let m: RegExpExecArray | null;
let k = 0;
while ((m = re.exec(src))) {
if (m.index > last) nodes.push(src.slice(last, m.index));
if (m[1] && m[2]) {
nodes.push(
<a key={k++} href={m[2]} target="_blank" rel="noreferrer" className="underline">
{m[1]}
</a>,
);
} else if (m[3]) {
nodes.push(<strong key={k++}>{m[3]}</strong>);
} else if (m[4]) {
nodes.push(
<code key={k++} className="rounded bg-muted px-1">
{m[4]}
</code>,
);
}
last = m.index + m[0].length;
}
if (last < src.length) nodes.push(src.slice(last));
return nodes;
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useState } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { Bold, Code, Heading2, Heading3, Italic, List, ListOrdered } from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
value: string;
onChange: (v: string) => void;
placeholder?: string;
className?: string;
};
export function RichEditor({ value, onChange, placeholder, className }: Props) {
const [tab, setTab] = useState<"edit" | "preview">("edit");
const [preview, setPreview] = useState("");
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
Placeholder.configure({ placeholder: placeholder ?? "توضیحات را اینجا بنویسید…" }),
Markdown.configure({ transformPastedText: true }),
],
content: value,
immediatelyRender: false,
onUpdate({ editor: e }) {
const md = e.storage.markdown?.getMarkdown?.() ?? e.getText();
onChange(md);
},
});
// Sync external value changes (e.g. when a new lesson is loaded).
useEffect(() => {
if (!editor) return;
const current = editor.storage.markdown?.getMarkdown?.() ?? editor.getText();
if (current !== value) {
editor.commands.setContent(value, false);
}
}, [value, editor]);
useEffect(() => {
if (tab !== "preview") return;
import("marked").then(async ({ marked }) => {
const DOMPurify = (await import("dompurify")).default;
const html = await marked(value);
setPreview(DOMPurify.sanitize(html));
});
}, [tab, value]);
const ToolBtn = ({
onClick,
active,
title,
children,
}: {
onClick: () => void;
active?: boolean;
title: string;
children: React.ReactNode;
}) => (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
onClick();
}}
title={title}
className={cn(
"grid h-7 w-7 place-items-center rounded transition-colors",
active
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{children}
</button>
);
return (
<div className={cn("rounded-md border border-input bg-background", className)}>
{/* Tab bar */}
<div className="flex items-center justify-between gap-1 border-b border-input px-2 py-1">
<div className="flex gap-0.5">
{editor && (
<>
<ToolBtn
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
title="توپر"
>
<Bold className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
title="مورب"
>
<Italic className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive("code")}
title="کد درون‌خطی"
>
<Code className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive("heading", { level: 2 })}
title="سرتیتر ۲"
>
<Heading2 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive("heading", { level: 3 })}
title="سرتیتر ۳"
>
<Heading3 className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive("bulletList")}
title="فهرست"
>
<List className="h-3.5 w-3.5" />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive("orderedList")}
title="فهرست عددی"
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolBtn>
</>
)}
</div>
<div className="flex gap-1 text-xs">
<button
type="button"
onClick={() => setTab("edit")}
className={cn(
"rounded px-2 py-0.5 transition-colors",
tab === "edit" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
)}
>
ویرایش
</button>
<button
type="button"
onClick={() => setTab("preview")}
className={cn(
"rounded px-2 py-0.5 transition-colors",
tab === "preview" ? "bg-primary/15 text-primary font-medium" : "text-muted-foreground hover:text-foreground",
)}
>
پیش‌نمایش
</button>
</div>
</div>
{/* Editor / Preview */}
{tab === "edit" ? (
<EditorContent
editor={editor}
dir="auto"
className="prose prose-sm max-w-none px-3 py-2 text-sm focus-within:outline-none [&_.ProseMirror]:min-h-[80px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground/50 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-right [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none"
/>
) : (
<div
dir="auto"
className="prose prose-sm max-w-none px-3 py-2 text-sm"
dangerouslySetInnerHTML={{ __html: preview || "<p class='text-muted-foreground text-sm'>پیش‌نمایشی نیست.</p>" }}
/>
)}
</div>
);
}
+12 -2
View File
@@ -33,10 +33,11 @@ export interface User {
export interface Certificate {
id: number;
userId: number;
publicId: string;
publicId?: string;
fullName: string;
issuedAt: string;
revokedAt?: string;
status: "pending" | "issued";
}
export type PhysicalOrderStatus = "requested" | "paid" | "shipped" | "cancelled";
@@ -91,6 +92,7 @@ export interface Lesson {
sortOrder: number;
xpReward: number;
isActive: boolean;
isPublished: boolean;
}
export interface Chapter {
@@ -99,6 +101,7 @@ export interface Chapter {
description: string;
icon: string;
sortOrder: number;
isPublished: boolean;
}
export interface TreeLesson {
@@ -122,6 +125,9 @@ export interface TreeChapter extends Chapter {
export interface TreeData {
chapters: TreeChapter[];
totalLessons: number;
completedLessons: number;
progressPct: number;
me: { xp: number; streak: number; hearts: number; isLocked: boolean; role: Role };
}
@@ -246,12 +252,15 @@ export interface Conversation {
archived: boolean;
}
export type LeaderboardTier = "champion" | "gold" | "silver" | "bronze";
export interface LeaderboardEntry {
userId: number;
name: string;
email: string;
xp: number;
rank: number;
tier: LeaderboardTier;
avatarVariant?: "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
avatarPalette?: string;
avatarPhoto?: string;
@@ -260,7 +269,8 @@ export interface LeaderboardEntry {
export interface LeaderboardData {
week: string;
entries: LeaderboardEntry[];
me: { rank: number; xp: number; streak: number };
totalParticipants: number;
me: { rank: number; xp: number; streak: number; tier: LeaderboardTier };
}
export interface NotificationPrefs {