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}
/>
);
}