"use client"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Award, ArrowLeft, BookOpenCheck, CheckCircle2, CalendarDays, Flame, Heart, Lock, MessageSquare, PlayCircle, Sparkles, TrendingUp, AlertTriangle, } from "lucide-react"; import { SkillTree } from "@/components/skill-tree"; import { Skeleton } from "@/components/ui/skeleton"; import { http, toUserError } from "@/lib/api"; 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"; import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts"; import { toast } from "@/components/providers"; import { SafeMarkdown } from "@/components/safe-markdown"; export default function CapPage() { return ( }> ); } function CapPageInner() { const router = useRouter(); const params = useSearchParams(); const rootRef = useRef(null); const user = useAuth((s) => s.user); const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ["tree"], queryFn: () => http.get("/api/tree"), refetchInterval: 60_000, }); const { data: eventsData } = useQuery({ queryKey: ["events", "upcoming-cap"], queryFn: () => http.get<{ events: MeetingEvent[] }>("/api/events"), staleTime: 60_000, }); const allLessons = (data?.chapters ?? []).flatMap((ch) => ch.lessons ?? []); const completedCount = data?.completedLessons ?? allLessons.filter((l) => l.progress?.passedQuiz).length; const curriculumDone = (data?.progressPct ?? 0) >= 100; useEffect(() => { const lesson = params.get("lesson"); if (lesson && rootRef.current) { rootRef.current.scrollIntoView({ behavior: "smooth" }); } }, [params]); const { data: certData } = useQuery({ queryKey: ["me", "certificate"], queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"), enabled: !!user && curriculumDone, }); useEffect(() => { if (!curriculumDone || !user) return; void http .post("/api/me/certificate/issue", {}) .then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] })) .catch((e) => toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"))); }, [curriculumDone, user, qc]); if (isLoading) { return ; } const me = data?.me; const all = allLessons; const lockedCount = all.filter((l) => l.locked).length; const inProgress = all.filter( (l) => !l.locked && !l.progress?.passedQuiz && (l.progress?.watchedPct ?? 0) > 0 && !l.progress?.quizUnlocked, ); const readyQuiz = all.filter((l) => !l.locked && !l.progress?.passedQuiz && !!l.progress?.quizUnlocked); const openAndNew = all.filter((l) => !l.locked && !l.progress?.passedQuiz); const firstLocked = all.find((l) => l.locked); const done = curriculumDone; 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 next = nextAction(inProgress, readyQuiz, openAndNew, firstLocked, done); return (

مسیر شما

درخت مهارت

الان روی گام بعدی تمرکز کنید — تماشا، آزمون، رویداد یا گواهینامه.

{formatStreakLabel(me?.streak ?? user?.streakCurrent ?? 0)} {formatFaNumber(me?.xp ?? user?.xp ?? 0)} امتیاز {" "} {hasInfiniteHearts(user?.role) ? `${INFINITE_HEARTS_LABEL} جان` : `${formatFaNumber(hearts)} جان`}
{lowHearts && (

فقط {formatFaNumber(hearts)} جان مانده

جان‌ها خودشان برمی‌گردند. تا برگشتن یک جان، آزمون تازه صبر می‌کند.

)} {done && (

دوره را تمام کردید

{certData?.certificate?.status === "pending" ? "درخواست گواهینامه ثبت شد — پس از تأیید تیم آموزش صادر می‌شود." : certData?.certificate?.status === "issued" ? "گواهینامه دیجیتال آماده است." : "گواهینامه دیجیتال آماده است."}

{certData?.certificate?.status === "issued" && certData.certificate.publicId ? ( ) : certData?.certificate?.status !== "pending" ? ( ) : null}
)}

پیشرفت کل

{pct}٪

{completedCount}

تکمیل‌شده

{inProgress.length}

در جریان

{lockedCount}

قفل‌شده

{(data?.chapters ?? []).map((ch) => { const chDone = ch.lessons.filter((l) => l.progress?.passedQuiz).length; return (
{ch.title} {chDone}/{ch.lessons.length}
); })}
{nextEvent && ( )}

{next.locked ? : } {next.locked ? "هنوز قفل است" : "گام بعدی شما"}

{next.title}

{next.desc}

{next.cta ? ( ) : next.prereqId ? ( ) : (
ابتدا درس پیش‌نیاز را کامل کنید
)}
{data && router.push(`/player/${id}`)} />}
تکمیل‌شده در دسترس آمادهٔ آزمون قفل‌شده
); } function nextAction( inProgress: TreeLesson[], readyQuiz: TreeLesson[], openAndNew: TreeLesson[], firstLocked: TreeLesson | undefined, done: boolean, ) { if (readyQuiz[0]) { const lesson = readyQuiz[0]; return { title: `آزمون «${lesson.title}» منتظر شماست`, desc: "آموخته‌هایتان را محک بزنید؛ هر پاسخ اشتباه یک جان کم می‌کند.", label: "شروع آزمون", cta: `/quiz/${lesson.id}`, icon: , locked: false, prereqId: null as number | null, }; } if (inProgress[0]) { const lesson = inProgress[0]; const pct = Math.round(lesson.progress?.watchedPct ?? 0); return { title: `ادامهٔ تماشای «${lesson.title}»`, desc: `${formatFaNumber(pct)}٪ تماشا شده — با رسیدن به آستانه، آزمون باز می‌شود.`, label: "ادامهٔ تماشا", cta: `/player/${lesson.id}`, icon: , locked: false, prereqId: null as number | null, }; } if (openAndNew[0]) { const lesson = openAndNew[0]; return { title: `شروع «${lesson.title}»`, desc: `${formatFaNumber(lesson.durationSeconds)} ثانیه تماشای تأییدشده · ${formatFaNumber(lesson.xpReward)} امتیاز پاداش در صورت تکمیل.`, label: "شروع درس", cta: `/player/${lesson.id}`, icon: , locked: false, prereqId: null as number | null, }; } if (firstLocked) { return { title: `«${firstLocked.title}» قفل است`, desc: "بعد از تکمیل صادقانهٔ درس پیش‌نیاز و قبولی در آزمونش، این شاخه باز می‌شود.", label: "", cta: null as string | null, icon: null, locked: true, prereqId: firstLocked.requiresLessonId ?? null, }; } if (done) { return { title: "همهٔ شاخه‌ها را آزاد کردید", desc: "تمام دروس تکمیل‌شده‌اند. گواهینامه را ببینید یا لیگ این هفته را فتح کنید.", label: "رفتن به لیگ‌ها", cta: "/leaderboard", icon: , locked: false, prereqId: null as number | null, }; } return { title: "مسیر آماده است", desc: "داده‌ای برای نمایش نیست.", label: "", cta: null as string | null, icon: null, locked: false, prereqId: null as number | null, }; } function CapSkeleton() { return (
{Array.from({ length: 3 }).map((_, i) => ( ))}
); } type CapAnnouncement = { title: string; body: string; route: string }; function PinnedAnnouncement() { const router = useRouter(); const scroller = useRef(null); const gesture = useRef<{ x: number; y: number; scroll: number; index: number; pointerId: number; axis: "x" | "y" | null; dx: number; } | null>(null); const moved = useRef(0); const [index, setIndex] = useState(0); const q = useQuery({ queryKey: ["announcement-pinned"], queryFn: () => http.get<{ announcement: CapAnnouncement | null; announcements?: CapAnnouncement[] }>("/api/announcements/pinned"), }); const items = q.data?.announcements?.length ? q.data.announcements : q.data?.announcement ? [q.data.announcement] : []; const count = items.length; const indexRef = useRef(0); const hoverRef = useRef(false); const downRef = useRef(false); const programmatic = useRef(0); const scrollSeq = useRef(0); const wantScroll = useRef(false); const scrollBehavior = useRef("smooth"); const [epoch, setEpoch] = useState(0); const stride = (el: HTMLDivElement) => { const card = el.firstElementChild as HTMLElement | null; const gap = 12; return (card?.offsetWidth || el.clientWidth || 1) + gap; }; const goTo = (next: number, behavior: ScrollBehavior = "smooth") => { if (count < 2) return; const i = ((next % count) + count) % count; const from = indexRef.current; const neighbor = Math.abs(i - from) === 1; scrollBehavior.current = neighbor && behavior === "smooth" ? "smooth" : "auto"; wantScroll.current = true; indexRef.current = i; setIndex(i); setEpoch((n) => n + 1); }; const stepBy = (delta: number) => { const from = indexRef.current; const dest = from + delta < 0 ? count - 1 : from + delta >= count ? 0 : from + delta; goTo(dest, Math.abs(dest - from) === 1 ? "smooth" : "auto"); }; const stepRef = useRef(stepBy); stepRef.current = stepBy; useLayoutEffect(() => { const el = scroller.current; if (!el || count < 2 || !wantScroll.current) return; wantScroll.current = false; const token = scrollSeq.current + 1; scrollSeq.current = token; programmatic.current = token; const left = indexRef.current * stride(el); let settledOnce = false; const finish = (force: boolean) => { if (settledOnce || scrollSeq.current !== token) return; if (gesture.current?.axis === "x") return; const target = indexRef.current * stride(el); const settled = Math.abs(el.scrollLeft - target) <= 2; if (!settled && !force) return; if (!settled) { el.style.scrollBehavior = "auto"; el.scrollLeft = target; } settledOnce = true; if (programmatic.current === token) programmatic.current = 0; el.removeEventListener("scrollend", onEnd); }; const onEnd = () => { if (scrollSeq.current !== token || !programmatic.current) return; finish(false); }; el.addEventListener("scrollend", onEnd); el.style.scrollBehavior = scrollBehavior.current === "smooth" ? "smooth" : "auto"; el.scrollTo({ left, behavior: scrollBehavior.current }); const soon = window.setTimeout(() => finish(false), scrollBehavior.current === "auto" ? 40 : 700); const later = window.setTimeout(() => finish(true), 1200); return () => { window.clearTimeout(soon); window.clearTimeout(later); el.removeEventListener("scrollend", onEnd); }; }, [index, epoch, count]); useEffect(() => { if (count < 2) return; const id = window.setInterval(() => { if (hoverRef.current || downRef.current) return; stepRef.current(1); }, 6000); return () => window.clearInterval(id); }, [count, epoch]); if (count === 0) return null; const hold = (hover: boolean | null, down: boolean | null) => { if (hover != null) hoverRef.current = hover; if (down != null) downRef.current = down; }; const go = (next: number) => goTo(next); const onTrackPointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; const el = scroller.current; if (!el) return; gesture.current = { x: e.clientX, y: e.clientY, scroll: el.scrollLeft, index: indexRef.current, pointerId: e.pointerId, axis: null, dx: 0, }; moved.current = 0; }; const onTrackPointerMove = (e: React.PointerEvent) => { const g = gesture.current; if (!g || g.pointerId !== e.pointerId) return; const dx = e.clientX - g.x; const dy = e.clientY - g.y; g.dx = dx; if (g.axis == null) { if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y"; } if (g.axis !== "x" || count < 2) return; const el = scroller.current; if (!el) return; if (!el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId); const width = stride(el); const max = width * (count - 1); const pulled = g.scroll - dx; const limited = Math.min(g.scroll + width, Math.max(g.scroll - width, pulled)); el.style.scrollBehavior = "auto"; el.scrollLeft = Math.min(max, Math.max(0, limited)); }; const onTrackPointerUp = (e: React.PointerEvent) => { const g = gesture.current; if (!g || g.pointerId !== e.pointerId) return; moved.current = Math.max(Math.abs(g.dx), Math.abs(e.clientY - g.y)); const el = scroller.current; if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId); if (g.axis !== "x" || count < 2) { gesture.current = null; if (el) el.style.scrollBehavior = ""; return; } const threshold = 40; let next = g.index; if (g.dx <= -threshold) next = g.index + 1; else if (g.dx >= threshold) next = g.index - 1; go(next); gesture.current = null; }; return (
hold(true, null)} onPointerLeave={() => { if (gesture.current?.axis === "x") { hold(false, null); return; } hold(false, false); setEpoch((n) => n + 1); }} onPointerDown={() => hold(null, true)} onPointerUp={() => hold(null, false)} onPointerCancel={() => hold(null, false)} >
{items.map((a, i) => ( ))}
{items.length > 1 && (
{items.map((a, i) => (
)}
); } function ZapIcon(props: { className?: string }) { return ( ); }