diff --git a/frontend/src/components/avatar-crop.tsx b/frontend/src/components/avatar-crop.tsx new file mode 100644 index 0000000..c50b466 --- /dev/null +++ b/frontend/src/components/avatar-crop.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Check, Loader2, Scan, X, ZoomIn } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +const OUTPUT_SIZE = 512; + +function clamp(v: number, lo: number, hi: number) { + return Math.min(hi, Math.max(lo, v)); +} + +export function AvatarCropDialog({ + file, + busy, + onCancel, + onCrop, +}: { + file: File | null; + busy?: boolean; + onCancel: () => void; + onCrop: (blob: Blob) => Promise | void; +}) { + const boxRef = useRef(null); + const [img, setImg] = useState<{ el: HTMLImageElement; w: number; h: number } | null>(null); + const [C, setC] = useState(280); + const [scale, setScale] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const drag = useRef<{ sx: number; sy: number; px: number; py: number } | null>(null); + + useEffect(() => { + if (!file) return; + const url = URL.createObjectURL(file); + const el = new Image(); + el.onload = () => setImg({ el, w: el.naturalWidth, h: el.naturalHeight }); + el.src = url; + return () => URL.revokeObjectURL(url); + }, [file]); + + useEffect(() => { + if (!img) return; + const box = boxRef.current; + const size = Math.min(box?.clientWidth ?? 280, 320); + setC(size); + const minScale = size / Math.min(img.w, img.h); + setScale(minScale); + setPan({ x: (size - img.w * minScale) / 2, y: (size - img.h * minScale) / 2 }); + }, [img]); + + const minScale = img ? C / Math.min(img.w, img.h) : 1; + const maxScale = minScale * 6; + + const clampPan = (x: number, y: number, s: number) => ({ + x: clamp(x, C - (img?.w ?? C) * s, 0), + y: clamp(y, C - (img?.h ?? C) * s, 0), + }); + + const onPointerDown = (e: React.PointerEvent) => { + (e.target as HTMLElement).setPointerCapture(e.pointerId); + drag.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y }; + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!drag.current) return; + const dx = e.clientX - drag.current.sx; + const dy = e.clientY - drag.current.sy; + setPan(clampPan(drag.current.px + dx, drag.current.py + dy, scale)); + }; + const onPointerUp = () => { + drag.current = null; + }; + + const zoom = (s: number) => { + const next = clamp(s, minScale, maxScale); + const cx = C / 2; + const cy = C / 2; + const imgX = (cx - pan.x) / scale; + const imgY = (cy - pan.y) / scale; + setPan(clampPan(cx - imgX * next, cy - imgY * next, next)); + setScale(next); + }; + + const setZoomIn = () => { + if (!img) return; + const ms = C / Math.min(img.w, img.h); + setScale(ms); + setPan({ x: (C - img.w * ms) / 2, y: (C - img.h * ms) / 2 }); + }; + + const confirm = async () => { + if (!img) return; + const cropSize = C / scale; + let cx = -pan.x / scale; + let cy = -pan.y / scale; + cx = clamp(cx, 0, img.w - cropSize); + cy = clamp(cy, 0, img.h - cropSize); + + const canvas = document.createElement("canvas"); + canvas.width = OUTPUT_SIZE; + canvas.height = OUTPUT_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img.el, cx, cy, cropSize, cropSize, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png") + ); + if (blob) await onCrop(blob); + }; + + const zoomPct = Math.round((scale / maxScale) * 100); + + return ( + !o && !busy && onCancel()}> + + + تنظیم عکس آواتار + + +
+ {img ? ( + <> +
+ پیش‌نمایش آواتار +
+
+ + + ) : ( +
+ +
+ )} +
+ +
+ + zoom((Number(e.target.value) / 100) * maxScale)} + aria-label="بزرگ‌نمایی" + className="w-full accent-primary" + /> +
+ + + +
+ + +
+
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/avatar-studio.tsx b/frontend/src/components/avatar-studio.tsx new file mode 100644 index 0000000..ae8a6f2 --- /dev/null +++ b/frontend/src/components/avatar-studio.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ArrowLeft, Camera, Check, Loader2, RotateCcw, Save, Trash2 } from "lucide-react"; +import { useAuth } from "@/lib/auth-store"; +import { queryClient } from "@/lib/query-client"; +import { apiForm, http, toUserError } from "@/lib/api"; +import type { User } from "@/lib/types"; +import { AVATAR_COLORS, AVATAR_VARIANTS, UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar"; +import { AvatarCropDialog } from "@/components/avatar-crop"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const PALETTES: { label: string; colors: string[] }[] = [ + { label: "پیش‌فرض", colors: AVATAR_COLORS }, + { label: "رزین", colors: ["#0f172a", "#38bdf8", "#f0abfc", "#a5f3fc", "#7c3aed", "#f5d0fe"] }, + { label: "صحرایی", colors: ["#1c1917", "#fbbf24", "#fb923c", "#fde68a", "#f97316", "#fed7aa"] }, + { label: "اسکاندی", colors: ["#022c22", "#34d399", "#fef3c7", "#a7f3d0", "#166534", "#d1fae5"] }, + { label: "نئون", colors: ["#18181b", "#a3e635", "#22d3ee", "#f97316", "#e879f9", "#fb7185"] }, + { label: "سلطنتی", colors: ["#20123a", "#9370db", "#e6e6fa", "#6b21a8", "#c084fc", "#f3e8ff"] }, +]; + +export function AvatarStudio({ user, onClose }: { user: User; onClose: () => void }) { + const setUser = useAuth((s) => s.setUser); + const fileRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + const [draftV, setDraftV] = useState(user.avatarVariant ?? "beam"); + const [draftP, setDraftP] = useState(user.avatarPalette ?? ""); + const [confirming, setConfirming] = useState(false); + + const savedV = user.avatarVariant ?? "beam"; + const savedP = user.avatarPalette ?? ""; + const photo = user.avatarPhoto ?? ""; + const dirty = draftV !== savedV || draftP !== savedP; + + useEffect(() => { + setDraftV(user.avatarVariant ?? "beam"); + setDraftP(user.avatarPalette ?? ""); + }, [user.avatarVariant, user.avatarPalette]); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["me"] }); + void queryClient.invalidateQueries({ queryKey: ["conversations"] }); + void queryClient.invalidateQueries({ queryKey: ["mentors"] }); + void queryClient.invalidateQueries({ queryKey: ["leaderboard"] }); + }; + + const doSave = async (body: { avatarVariant?: string; avatarPalette?: string; avatarPhoto?: string }) => { + setBusy(true); + setError(null); + try { + const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", body); + setUser(fresh); + invalidate(); + } catch (e) { + setError(toUserError(e, "خطا در ذخیره")); + } finally { + setBusy(false); + } + }; + + const onSave = async () => { + if (photo && (draftV !== savedV || draftP !== savedP)) { + setConfirming(true); + return; + } + await doSave({ avatarVariant: draftV, avatarPalette: draftP }); + }; + + const confirmReplace = async () => { + setConfirming(false); + await doSave({ avatarVariant: draftV, avatarPalette: draftP, avatarPhoto: "" }); + }; + + const reset = () => { + setDraftV(savedV); + setDraftP(savedP); + setError(null); + }; + + const onUpload = async (blob: Blob) => { + setBusy(true); + setError(null); + try { + const form = new FormData(); + form.append("file", new File([blob], "avatar.png", { type: "image/png" }), "avatar.png"); + const { url } = await apiForm<{ url: string }>("/api/me/avatar-photo", form); + const { user: fresh } = await http.put<{ user: User }>("/api/me/avatar", { avatarPhoto: url }); + setUser(fresh); + invalidate(); + setPending(null); + } catch (e) { + setError(toUserError(e, "خطا در آپلود")); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ + + {photo ? : } + +
+
+

{user.name}

+

پیش‌نمایش زنده — تغییرات تا ذخیره اعمال نشده است

+ {dirty && ( + + + تغییرات ذخیره‌نشده + + )} +
+
+ +
+

سبک

+
+ {AVATAR_VARIANTS.map((v) => ( + + ))} +
+
+ +
+

رنگ‌ها

+
+ {PALETTES.map((p) => { + const active = JSON.stringify(p.colors) === draftP; + return ( + + ); + })} +
+
+ +
+ + {photo && ( + + )} + { + const f = e.target.files?.[0]; + if (f) setPending(f); + e.target.value = ""; + }} + /> +
+ + {error &&

{error}

} + +
+
+ {dirty && ( + + )} +
+
+ {dirty ? ( + + ) : ( + + )} +
+
+ + !busy && setPending(null)} + onCrop={(blob) => onUpload(blob)} + /> + + !o && setConfirming(false)} + > + + + حذف عکس؟ + +
+ + + + + +
+

+ برای اعمال این سبک، عکس فعلی آواتارت حذف می‌شود و آواتار گرافیکی جایگزین آن می‌شود. مطمئنی؟ +

+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/certificate-card.tsx b/frontend/src/components/certificate-card.tsx new file mode 100644 index 0000000..855b138 --- /dev/null +++ b/frontend/src/components/certificate-card.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { Award, Copy } from "lucide-react"; +import { BrandMark } from "@/components/brand-mark"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/providers"; + +export type CertificateCardData = { + publicId: string; + fullName: string; + issuedAt: string; + course: string; +}; + +export function CertificateCard({ + cert, + showOwnerLinks = false, + children, +}: { + cert: CertificateCardData; + showOwnerLinks?: boolean; + children?: React.ReactNode; +}) { + const copyLink = async () => { + try { + await navigator.clipboard.writeText(window.location.href); + toast.success("لینک گواهینامه کپی شد"); + } catch { + toast.error("کپی ممکن نشد"); + } + }; + + return ( +
+
+
+
+ + +

گواهینامه پایان دوره

+

{cert.fullName}

+

+ با موفقیت دوره {cert.course} را به پایان رسانده و همه درس‌های فعال را پاس کرده‌اند. +

+

+ تاریخ صدور: {new Date(cert.issuedAt).toLocaleDateString("fa-IR")} +

+

+ تأیید: {cert.publicId} +

+
+ + {showOwnerLinks && ( + + )} +
+
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/challenge-cards.tsx b/frontend/src/components/challenge-cards.tsx new file mode 100644 index 0000000..96d31c0 --- /dev/null +++ b/frontend/src/components/challenge-cards.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { CheckCircle2, Trophy } from "lucide-react"; +import { http, toUserError } from "@/lib/api"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { cn, formatFaNumber } from "@/lib/utils"; + +export type ChallengeView = { + id: number; + title: string; + description: string; + targetXp: number; + startsAt: string; + endsAt: string; + isActive: boolean; + myXp: number; + completed: boolean; +}; + +export function ChallengeCards({ className }: { className?: string }) { + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ["challenges"], + queryFn: () => http.get<{ challenges: ChallengeView[] }>("/api/challenges"), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+

{toUserError(error)}

+ +
+ ); + } + + const list = data?.challenges ?? []; + if (list.length === 0) return null; + + return ( +
+ {list.map((c) => { + const pct = Math.min(100, Math.round((c.myXp / Math.max(c.targetXp, 1)) * 100)); + return ( +
+
+
+ {c.completed ? : } +
+
+

{c.title}

+ {c.description &&

{c.description}

} +
+
+ + {formatFaNumber(c.myXp)} / {formatFaNumber(c.targetXp)} امتیاز + + + {c.completed ? "تکمیل شد" : `${formatFaNumber(pct)}٪`} + +
+ +
+

+ تا{" "} + {new Date(c.endsAt).toLocaleString("fa-IR", { + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+
+
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/community-panel.tsx b/frontend/src/components/community-panel.tsx new file mode 100644 index 0000000..f2849eb --- /dev/null +++ b/frontend/src/components/community-panel.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { HeartHandshake, LogIn, MessageSquareHeart } from "lucide-react"; +import Link from "next/link"; +import { http, toUserError } from "@/lib/api"; +import { useAuth } from "@/lib/auth-store"; +import { Panel } from "@/components/panel"; +import { Button } from "@/components/ui/button"; + +export type CommunityPayload = { + donation: { + enabled: boolean; + note: string; + mode: "chat"; + contactId?: number; + draft: string; + }; +}; + +function openDonationChat(contactId: number | undefined, draft: string) { + window.dispatchEvent( + new CustomEvent("pargar:chat", { + detail: { open: true, with: contactId, draft }, + }), + ); +} + +export function CommunityPanel() { + const user = useAuth((s) => s.user); + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ["community", user?.id ?? "guest"], + queryFn: () => http.get("/api/community"), + }); + + if (isLoading) { + return ( +
+ ); + } + + if (isError) { + return ( +
+

{toUserError(error)}

+ +
+ ); + } + + const donation = data?.donation; + + return ( +
+ } + tint="bg-accent/15 text-accent" + > + {donation?.enabled ? ( +
+

{donation.note}

+
+ جزئیات واریز فقط در پیام خصوصی رد و بدل می‌شود؛ اینجا چیزی عمومی نیست. +
+ {user ? ( + + ) : ( + + )} +
+ ) : ( +

حمایت فعلاً غیرفعال است.

+ )} +
+
+ ); +} diff --git a/frontend/src/components/events-panel.tsx b/frontend/src/components/events-panel.tsx new file mode 100644 index 0000000..b051288 --- /dev/null +++ b/frontend/src/components/events-panel.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CalendarDays, CalendarPlus, Check, ExternalLink, Flame, MapPin, Users } from "lucide-react"; +import { api, http, toUserError } from "@/lib/api"; +import type { MeetingEvent } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Panel } from "@/components/panel"; +import { EmptyState } from "@/components/empty-state"; +import { cn } from "@/lib/utils"; +import { toast } from "@/components/providers"; + +export function EventsPanel() { + const qc = useQueryClient(); + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ["events"], + queryFn: () => http.get<{ events: MeetingEvent[] }>("/api/events"), + }); + + const toggle = useMutation({ + mutationFn: async (e: MeetingEvent) => { + if (e.rsvped) { + await api(`/api/events/${e.id}/rsvp`, { method: "DELETE" }); + } else { + await api(`/api/events/${e.id}/rsvp`, { method: "POST" }); + } + }, + onSuccess: (_d, e) => { + toast.success(e.rsvped ? "ثبت‌نام لغو شد" : `در «${e.title}» ثبت‌نام شدید`); + qc.invalidateQueries({ queryKey: ["events"] }); + qc.invalidateQueries({ queryKey: ["notifications"] }); + }, + onError: (err) => toast.error(toUserError(err, "به‌روزرسانی ثبت‌نام ممکن نشد")), + }); + + if (isLoading) { + return ( +
+ {[0, 1].map((i) => ( +
+
+ + +
+ + +
+
+ + + +
+ +
+
+ ))} +
+ ); + } + + if (isError) { + return ( +
+

{toUserError(error)}

+ +
+ ); + } + + const events = data?.events ?? []; + const past = events.filter((e) => new Date(e.endsAt).getTime() < Date.now()); + const upcoming = events.filter((e) => new Date(e.endsAt).getTime() >= Date.now()); + + const fmt = (iso: string) => + new Date(iso).toLocaleString("fa-IR", { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + + return ( + } + tint="bg-primary/10 text-primary" + className="space-y-0" + > +
+ {events.length === 0 && ( + } + title="هنوز رویدادی برنامه‌ریزی نشده" + description="به‌زودی دوباره سر بزنید — یا از یک منتور بخواهید رویدادی برگزار کند." + /> + )} + + {upcoming.length > 0 && ( +
+
+ + + +

پیش‌رو

+
+
+ {upcoming.map((e) => ( + +
+ +
+ + {eventTypeLabel(e.eventType)} + + {fmt(e.startsAt)} +
+

{e.title}

+

{e.description}

+
+ {e.externalUrl && ( + + پیوستن + + )} + +
+
+ +
+
+ + ))} +
+
+ )} + + {past.length > 0 && ( +
+
+ + + +

گذشته

+
+
+ {past.map((e) => ( +
+
+

{e.title}

+

{fmt(e.startsAt)}

+
+ {eventTypeLabel(e.eventType)} +
+ ))} +
+
+ )} + +

+ همه رویدادها آنلاین هستند — لینک پیوستن در هر کارت است. +

+
+ + ); +} + +function eventTypeLabel(eventType: string): string { + if (eventType === "workshop") return "کارگاه"; + if (eventType === "meet") return "جلسه"; + if (eventType === "zoom") return "آنلاین"; + return eventType; +} \ No newline at end of file diff --git a/frontend/src/components/hearts-meter.tsx b/frontend/src/components/hearts-meter.tsx new file mode 100644 index 0000000..7ce2299 --- /dev/null +++ b/frontend/src/components/hearts-meter.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Heart, Infinity as InfinityIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { MAX_HEARTS, clampHearts } from "@/lib/hearts"; + +export function HeartsMeter({ + hearts, + size = "md", + infinite = false, +}: { + hearts: number; + size?: "sm" | "md" | "lg"; + infinite?: boolean; +}) { + const dims = size === "lg" ? "h-9 w-9" : size === "sm" ? "h-5 w-5" : "h-7 w-7"; + if (infinite) { + return ( + + + + + ); + } + const n = clampHearts(hearts); + return ( +
+ {Array.from({ length: MAX_HEARTS }, (_, i) => { + const on = i < n; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/leaderboard-panel.tsx b/frontend/src/components/leaderboard-panel.tsx new file mode 100644 index 0000000..bfaafb3 --- /dev/null +++ b/frontend/src/components/leaderboard-panel.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Crown, 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 { cn, formatFaNumber } from "@/lib/utils"; +import { Panel } from "@/components/panel"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/empty-state"; +import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar"; +import { ChallengeCards } from "@/components/challenge-cards"; +import { Button } from "@/components/ui/button"; + +const medalClasses = { + 1: "bg-gold text-gold-foreground shadow-offset-sm", + 2: "bg-secondary text-secondary-foreground shadow-offset-sm", + 3: "bg-accent text-accent-foreground shadow-offset-sm", +}; + +export function LeaderboardPanel() { + const user = useAuth((s) => s.user); + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ["leaderboard"], + queryFn: () => http.get("/api/leaderboard/weekly"), + }); + const { resetLabel, nextResetJalali } = useResetCountdown(); + + if (isLoading) { + return ( +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+ ); + } + + if (isError) { + return ( +
+

{toUserError(error)}

+ +
+ ); + } + + const entries = data?.entries ?? []; + const me = data?.me; + + const podium = entries.slice(0, 3); + const rest = entries.slice(3); + + return ( +
+ + + لیگ هفتگی · {data?.week} + + } + description="لیگ هفتگی جدا از چالش ماهانه است. رتبه‌بندی هر دوشنبهٔ میلادی (آغاز هفتهٔ ISO) صفر می‌شود." + icon={} + tint="bg-gold/10 text-gold" + > +
+
+ + + ریست دوشنبه · {resetLabel} + + + معادل جلالی: {nextResetJalali} + + {me && me.rank > 0 && ( + + + رتبه {fa(me.rank)} شمایید + + )} +
+ +
+
+ + + +

+ {formatFaNumber(Math.round(me?.xp ?? 0))} +

+

+ {me && me.rank > 0 ? "امتیاز شما این هفته" : "هنوز امتیازی کسب نکرده‌اید — بروید به دست آورید"} +

+
+
+ + {podium.length > 0 && } + +
+ {rest.map((e) => { + const isMe = !!user && e.userId === user.id; + return ( +
+ + {formatFaNumber(e.rank)} + + + {e.name} + {isMe && ( + + شما + + )} + + {formatFaNumber(Math.round(e.xp))} امتیاز + +
+ ); + })} + {rest.length === 0 && entries.length === 0 && ( + } + title="هنوز رتبه‌بندی وجود ندارد" + description="با تکمیل صادقانه درس‌ها امتیاز کسب کنید تا در لیگ بالا بروید." + /> + )} +
+
+
+
+ ); +} + +function Podium({ entries, myId }: { entries: LeaderboardEntry[]; myId?: number }) { + if (entries.length === 1) { + return ( +
+ +
+ ); + } + + if (entries.length === 2) { + const ordered = [...entries].sort((a, b) => a.rank - b.rank); + return ( +
+ {ordered.map((e) => ( + + ))} +
+ ); + } + + // Classic 2nd · 1st · 3rd visual order + return ( +
+ {[1, 0, 2].map((idx) => { + const e = entries[idx]; + if (!e) return
; + return ; + })} +
+ ); +} + +function PodiumCard({ + entry, + tall, + myId, + className, +}: { + entry: LeaderboardEntry; + tall?: boolean; + myId?: number; + className?: string; +}) { + const isMe = myId != null && entry.userId === myId; + return ( +
+ +

{entry.name}

+

+ {formatFaNumber(Math.round(entry.xp))} امتیاز +

+
+ {entry.rank === 1 ? : formatFaNumber(entry.rank)} +
+
+ ); +} + +function fa(n: number) { + return n.toLocaleString("fa-IR"); +} + +function useResetCountdown() { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(t); + }, []); + + return useMemo(() => { + const d = new Date(now); + const day = d.getDay(); + const add = ((1 - day + 7) % 7) || 7; + const target = new Date(d); + target.setDate(d.getDate() + add); + target.setHours(0, 0, 0, 0); + const diff = Math.max(target.getTime() - now, 0); + const days = Math.floor(diff / 86_400_000); + const hours = Math.floor((diff % 86_400_000) / 3_600_000); + const minutes = Math.floor((diff % 3_600_000) / 60_000); + let resetLabel = `${fa(minutes)} دقیقه`; + if (days > 0) resetLabel = `${fa(days)} روز ${fa(hours)} ساعت`; + else if (hours > 0) resetLabel = `${fa(hours)} ساعت ${fa(minutes)} دقیقه`; + const nextResetJalali = target.toLocaleDateString("fa-IR", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + return { resetLabel, nextResetJalali }; + }, [now]); +} diff --git a/frontend/src/components/notification-bell.tsx b/frontend/src/components/notification-bell.tsx new file mode 100644 index 0000000..d2e597a --- /dev/null +++ b/frontend/src/components/notification-bell.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bell, CheckCheck } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { faIR } from "date-fns/locale"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { http } from "@/lib/api"; +import type { NotificationItem } from "@/lib/types"; +import { useRouter } from "next/navigation"; + +export function NotificationBell() { + const router = useRouter(); + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ["notifications"], + queryFn: () => http.get<{ notifications: NotificationItem[]; unread: number }>("/api/notifications?limit=12"), + }); + const unread = data?.unread ?? 0; + + const markAll = async () => { + await http.post("/api/notifications/read-all"); + qc.invalidateQueries({ queryKey: ["notifications"] }); + qc.invalidateQueries({ queryKey: ["me"] }); + }; + + const categoryDot: Record = { + progress: "bg-primary", + gamification: "bg-gold", + mentor: "bg-accent", + event: "bg-secondary", + }; + + return ( + + + + + +
+ اعلان‌ها +
+ {unread > 0 && {unread} جدید} + +
+
+
+ {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ )} + {!isLoading && (!data?.notifications || data.notifications.length === 0) && ( +
+ اعلانی ندارید. +
+ با قبولی در یک آزمون یا ثبت‌نام در رویداد، اعلان می‌گیرید. +
+ )} + {data?.notifications.map((n) => ( + + ))} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/notifications-panel.tsx b/frontend/src/components/notifications-panel.tsx new file mode 100644 index 0000000..ae061f4 --- /dev/null +++ b/frontend/src/components/notifications-panel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { CheckCheck, Inbox } from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; +import { faIR } from "date-fns/locale"; +import { http } from "@/lib/api"; +import type { NotificationItem } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/empty-state"; +import { cn } from "@/lib/utils"; + +const categoryMap: Record< + string, + { label: string; dot: string; classes: string } +> = { + progress: { label: "پیشرفت", dot: "bg-primary", classes: "bg-primary/10 text-primary" }, + gamification: { label: "بازی‌سازی", dot: "bg-gold", classes: "bg-gold/15 text-gold" }, + mentor: { label: "منتور", dot: "bg-accent", classes: "bg-accent/15 text-accent" }, + event: { label: "رویداد", dot: "bg-secondary", classes: "bg-secondary/15 text-secondary" }, +}; + +export function NotificationsPanel() { + const router = useRouter(); + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ + queryKey: ["notifications"], + queryFn: () => http.get<{ notifications: NotificationItem[]; unread: number }>("/api/notifications?limit=50"), + }); + + const markAll = useMutation({ + mutationFn: () => http.post("/api/notifications/read-all"), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["notifications"] }); + qc.invalidateQueries({ queryKey: ["me"] }); + }, + }); + + const notifications = data?.notifications ?? []; + + return ( +
+
+

+ {data?.unread ? `${data.unread} خوانده‌نشده` : "همه‌چیز به‌روز است"} · {notifications.length} اعلان اخیر +

+ +
+ + {isLoading && ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ )} + + {!isLoading && notifications.length === 0 && ( + } + title="هنوز اعلانی نیست" + description="پیشرفت، زنجیرهٔ فعالیت، یادآوری ثبت‌نام و پاسخ منتور اینجا می‌آیند." + /> + )} + +
+ {notifications.map((n) => { + const cat = categoryMap[n.category] ?? { label: n.category, dot: "bg-primary", classes: "bg-muted text-muted-foreground" }; + return ( + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/profile-certificate-slot.tsx b/frontend/src/components/profile-certificate-slot.tsx new file mode 100644 index 0000000..e9d41f0 --- /dev/null +++ b/frontend/src/components/profile-certificate-slot.tsx @@ -0,0 +1,185 @@ +"use client"; + +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 { http, toUserError } from "@/lib/api"; +import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/providers"; +import { cn, formatFaNumber } from "@/lib/utils"; + +type MineCertificateResponse = { + certificate: Certificate | null; + eligible: boolean; + physicalOrder: CertificatePhysicalOrder | null; + physical: PhysicalCertSettings; +}; + +const ORDER_LABEL: Record = { + requested: "درخواست فیزیکی ثبت شد", + paid: "پرداخت تأیید شد", + shipped: "ارسال شد", + cancelled: "درخواست فیزیکی لغو شده", +}; + +export function ProfileCertificateSlot() { + const router = useRouter(); + const qc = useQueryClient(); + const [issuing, setIssuing] = useState(false); + const autoIssueTried = useRef(false); + + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey: ["me", "certificate"], + queryFn: () => http.get("/api/me/certificate"), + }); + + 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). + useEffect(() => { + if (!eligible || cert || autoIssueTried.current) return; + autoIssueTried.current = true; + setIssuing(true); + void http + .post<{ certificate: Certificate }>("/api/me/certificate/issue", {}) + .then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] })) + .catch((e) => toast.error(toUserError(e, "صدور خودکار گواهینامه ممکن نشد — دستی تلاش کنید"))) + .finally(() => setIssuing(false)); + }, [eligible, cert, qc]); + + const copyLink = async () => { + if (!cert?.publicId) return; + const url = `${window.location.origin}/c/${cert.publicId}`; + try { + await navigator.clipboard.writeText(url); + toast.success("لینک گواهینامه کپی شد"); + } catch { + toast.error("کپی ممکن نشد"); + } + }; + + const openCert = () => { + if (!cert?.publicId) return; + router.push(`/c/${cert.publicId}`); + }; + + const claim = async () => { + setIssuing(true); + try { + const res = await http.post<{ certificate: Certificate }>("/api/me/certificate/issue", {}); + await qc.invalidateQueries({ queryKey: ["me", "certificate"] }); + if (res.certificate?.publicId) { + router.push(`/c/${res.certificate.publicId}`); + } + } catch (e) { + toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")); + } finally { + setIssuing(false); + } + }; + + 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 ( +
+
+ + + +
+

گواهینامه پایان دوره

+

جایگاه ثابت گواهی شما در پروفایل

+
+
+ + {isError ? ( +
+

{toUserError(error)}

+ +
+ ) : isLoading || (eligible && !cert && issuing) ? ( +
+ + در حال بارگذاری… +
+ ) : cert ? ( +
+
+

{cert.fullName}

+

+ صادر شده در {new Date(cert.issuedAt).toLocaleDateString("fa-IR")} +

+

+ {cert.publicId} +

+
+ {order && order.status !== "cancelled" ? ( +

+ {ORDER_LABEL[order.status] ?? order.status} + {order.trackingCode ? ` · کد پیگیری ${order.trackingCode}` : ""} +

+ ) : null} +
+ + + {physicalOpen ? ( + + ) : null} +
+
+ ) : eligible ? ( +
+

+ دوره را تمام کرده‌اید. گواهینامه دیجیتال آمادهٔ صدور است. +

+ +
+ ) : ( +
+ +
+

هنوز صادر نشده

+

+ با پاس کردن همه درس‌های فعال، گواهینامه همین‌جا نمایش داده می‌شود + {physical?.enabled + ? `؛ در صورت فعال بودن، نسخه فیزیکی (پرداخت آفلاین) تا ${formatFaNumber(physical.windowDays)} روز پس از صدور قابل درخواست است.` + : "."} +

+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/quiz-experience.tsx b/frontend/src/components/quiz-experience.tsx new file mode 100644 index 0000000..73da50e --- /dev/null +++ b/frontend/src/components/quiz-experience.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeft, + CheckCircle2, + ChevronLeft, + Flame, + Loader2, + Lock, + PartyPopper, + Sparkles, + Zap, +} from "lucide-react"; +import { api, http, toUserError } from "@/lib/api"; +import type { QuizData, QuizResult } from "@/lib/types"; +import { HeartsMeter } from "@/components/hearts-meter"; +import { useLiveUser } from "@/hooks/use-live-user"; +import { hasInfiniteHearts } from "@/lib/hearts"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { cn, formatFaNumber, formatOptionLetter, formatStreakLabel } from "@/lib/utils"; + +export function QuizExperience({ lessonId }: { lessonId: number }) { + const router = useRouter(); + const qc = useQueryClient(); + const liveUser = useLiveUser(); + const [answers, setAnswers] = useState>({}); + const [result, setResult] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { data, isLoading, isError, error: loadError } = useQuery({ + queryKey: ["quiz", lessonId], + queryFn: () => http.get(`/api/lessons/${lessonId}/quiz`), + retry: false, + meta: { silentError: true }, + }); + + const questions = data?.questions ?? []; + const answeredCount = Object.keys(answers).length; + const allAnswered = questions.length > 0 && answeredCount === questions.length; + + const select = (qid: number, idx: number) => + setAnswers((prev) => ({ ...prev, [qid]: idx })); + + const submit = async () => { + if (!allAnswered || submitting) return; + setSubmitting(true); + setError(null); + try { + const ordered = questions.map((q) => answers[q.id] ?? -1); + const res = await api(`/api/lessons/${lessonId}/quiz/submit`, { + method: "POST", + body: { answers: ordered }, + }); + setResult(res); + qc.invalidateQueries({ queryKey: ["tree"] }); + qc.invalidateQueries({ queryKey: ["me"] }); + qc.invalidateQueries({ queryKey: ["notifications"] }); + qc.invalidateQueries({ queryKey: ["leaderboard"] }); + if (res.locked && !hasInfiniteHearts(liveUser?.role)) router.push("/lockout"); + } catch (e) { + setError((e as Error)?.message ?? "خطایی رخ داد"); + } finally { + setSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+
+
+ ); + } + + if (isError || (loadError && !data)) { + const msg = toUserError( + loadError, + error ?? "بارگذاری آزمون ممکن نشد. ابتدا درس را تا آستانه تماشا کنید.", + ); + return ( + + +

آزمون در دسترس نیست

+

{msg}

+ +
+ ); + } + + if (data?.passed) { + return ( + + +

درس تکمیل شده

+

شما قبلاً این آزمون را با موفقیت گذرانده‌اید.

+
+ + +
+
+ ); + } + + const infiniteHearts = hasInfiniteHearts(liveUser?.role); + + if (result) { + return ( + { + setResult(null); + setAnswers({}); + qc.invalidateQueries({ queryKey: ["quiz", lessonId] }); + }} + /> + ); + } + + return ( +
+
+
+

آزمون دروس

+

آموخته‌هایتان را ثابت کنید

+
+ +
+ {!infiniteHearts && ( +

+ هر پاسخ اشتباه یک قلب کم می‌کند. با از دست دادن هر سه قلب، حساب محدود می‌شود و ادامهٔ مسیر + فقط پس از بررسی مدیریت ممکن است. +

+ )} + {infiniteHearts && ( +

جان شما نامحدود است — می‌توانید بدون محدودیت آزمون را تکرار کنید.

+ )} + +
+ {questions.map((q, qi) => ( + + +
+ + {qi + 1} + +

{q.question}

+
+
+ {q.options.map((opt, oi) => { + const active = answers[q.id] === oi; + return ( + + ); + })} +
+
+
+ ))} +
+ + {error && ( +
+ {error} +
+ )} + +
+

+ {answeredCount}/{questions.length} پاسخ‌داده‌شده +

+ +
+
+ ); +} + +function ResultScreen({ + result, + lessonId, + onRetake, + infiniteHearts, +}: { + result: QuizResult; + lessonId: number; + onRetake: () => void; + infiniteHearts: boolean; +}) { + const router = useRouter(); + const pct = result.total ? Math.round((result.correctCount / result.total) * 100) : 0; + + if (result.passed) return router.push("/cap")} onLeagues={() => router.push("/leaderboard")} />; + + const showLock = result.locked && !infiniteHearts; + + return ( +
+ +
+ +
+

هنوز کافی نیست

+

+ {formatFaNumber(result.correctCount)}/{formatFaNumber(result.total)} پاسخ درست. درس را مرور کنید و دوباره تلاش کنید + {!infiniteHearts && " — هر پاسخ اشتباه یک قلب کم می‌کند"}. +

+
+ +

{pct}٪ درست

+
+
+ {showLock ? ( + + ) : ( + + )} +
+
+

+ قلب‌های باقی‌مانده: +

+
+ ); +} + +function HeartsMeterInline({ hearts, infinite }: { hearts: number; infinite?: boolean }) { + return ; +} + +function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) { + return ( +
+
+ +
+

آزمون قبول شد!

+

پیشرفت جدی. درس‌های بعدی آزاد شدند.

+
+ + {formatFaNumber(result.xpEarned)} امتیاز + + + {formatStreakLabel(result.streak)} + +
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/register-form.tsx b/frontend/src/components/register-form.tsx new file mode 100644 index 0000000..7943fef --- /dev/null +++ b/frontend/src/components/register-form.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, ArrowRight, Loader2, RefreshCw, UserPlus } from "lucide-react"; +import { useAuth } from "@/lib/auth-store"; +import { http, toUserError } from "@/lib/api"; +import type { User } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { + validateEmail, + validateName, + validatePassword, + clampPhoneInput, + normalizePhone, + PHONE_INPUT_MAX_LEN, + validatePhone, + validateSecurityQA, + validateUsername, +} from "@/lib/validation"; + +const STEPS = [ + { id: 1, title: "هویت", hint: "نام، شناسه و تلفن" }, + { id: 2, title: "امنیت", hint: "ایمیل و سوال امنیتی" }, + { id: 3, title: "گذرواژه", hint: "رمز و کد امنیتی" }, +] as const; + +type CaptchaChallenge = { + challengeId: string; + imageBase64: string; +}; + +type RegisterFormProps = { + endpoint: string; + submitLabel: string; + description?: string; +}; + +function Field({ + id, + label, + children, +}: { + id: string; + label: string; + children: ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +export function RegisterForm({ endpoint, submitLabel, description }: RegisterFormProps) { + const router = useRouter(); + const setSession = useAuth((s) => s.setSession); + + const [step, setStep] = useState(1); + const [name, setName] = useState(""); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [phone, setPhone] = useState(""); + const [securityQuestion, setSecurityQuestion] = useState(""); + const [securityAnswer, setSecurityAnswer] = useState(""); + const [captchaAnswer, setCaptchaAnswer] = useState(""); + const [challenge, setChallenge] = useState(null); + const [captchaLoading, setCaptchaLoading] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadCaptcha = useCallback(async () => { + setCaptchaLoading(true); + setCaptchaAnswer(""); + try { + const data = await http.get("/api/auth/captcha"); + setChallenge(data); + } catch (err) { + setChallenge(null); + setError(toUserError(err, "بارگذاری کد امنیتی ممکن نشد")); + } finally { + setCaptchaLoading(false); + } + }, []); + + useEffect(() => { + if (step === 3) { + void loadCaptcha(); + } + }, [step, loadCaptcha]); + + const goNext = () => { + setError(null); + if (step === 1) { + const nErr = validateName(name); + if (nErr) { + setError(nErr); + return; + } + const uErr = validateUsername(username); + if (uErr) { + setError(uErr); + return; + } + const pErr = validatePhone(phone); + if (pErr) { + setError(pErr); + return; + } + setStep(2); + return; + } + if (step === 2) { + const eErr = validateEmail(email); + if (eErr) { + setError(eErr); + return; + } + const sErr = validateSecurityQA(securityQuestion, securityAnswer); + if (sErr) { + setError(sErr); + return; + } + setStep(3); + } + }; + + const goBack = () => { + setError(null); + setStep((s) => Math.max(1, s - 1)); + }; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (step < 3) { + goNext(); + return; + } + setError(null); + const pwErr = validatePassword(password, passwordConfirm); + if (pwErr) { + setError(pwErr); + return; + } + if (!challenge) { + setError("ابتدا کد امنیتی را بارگذاری کنید"); + void loadCaptcha(); + return; + } + setLoading(true); + try { + const data = await http.post<{ accessToken: string; refreshToken: string; user: User }>( + endpoint, + { + name: name.trim(), + username: username.trim().toLowerCase(), + email: email.trim().toLowerCase(), + password, + passwordConfirm, + phone: normalizePhone(phone), + securityQuestion: securityQuestion.trim(), + securityAnswer: securityAnswer.trim(), + challengeId: challenge.challengeId, + captchaAnswer, + }, + ); + setSession(data.accessToken, data.refreshToken, data.user); + router.push("/cap"); + } catch (err) { + setError(toUserError(err, "ثبت‌نام ناموفق بود")); + void loadCaptcha(); + } finally { + setLoading(false); + } + }; + + const currentStep = STEPS[step - 1]; + const progressPct = (step / STEPS.length) * 100; + + return ( +
+ {description && ( +

{description}

+ )} + +
+
+ + گام {step} از {STEPS.length} + + {currentStep?.hint} +
+
+
+
+
    + {STEPS.map((s) => { + const active = step === s.id; + const done = step > s.id; + return ( +
  1. + {s.title} +
  2. + ); + })} +
+
+ +
void submit(ev)} className="space-y-4"> + {step === 1 && ( + <> + + setName(e.target.value)} + autoComplete="name" + autoFocus + /> + + + setUsername(e.target.value)} + dir="ltr" + className="text-left" + autoComplete="username" + /> + + + setPhone(clampPhoneInput(e.target.value))} + dir="ltr" + className="text-left" + placeholder="0912…" + autoComplete="tel" + inputMode="tel" + maxLength={PHONE_INPUT_MAX_LEN} + /> + + + )} + + {step === 2 && ( + <> + + setEmail(e.target.value)} + dir="ltr" + className="text-left" + autoComplete="email" + autoFocus + /> + + + setSecurityQuestion(e.target.value)} + placeholder="مثلاً نام مدرسهٔ اول؟" + /> + + + setSecurityAnswer(e.target.value)} + autoComplete="off" + dir="rtl" + placeholder="جواب سوال امنیتی" + /> + + + )} + + {step === 3 && ( + <> + + setPassword(e.target.value)} + minLength={8} + autoComplete="new-password" + autoFocus + placeholder="حداقل ۸ نویسه" + /> + + + setPasswordConfirm(e.target.value)} + minLength={8} + autoComplete="new-password" + placeholder="همان رمز را دوباره وارد کنید" + /> + +
+
+ + +
+
+
+ {captchaLoading || !challenge?.imageBase64 ? ( + … + ) : ( + // eslint-disable-next-line @next/next/no-img-element + کد امنیتی + )} +
+ setCaptchaAnswer(e.target.value.toUpperCase())} + required + dir="ltr" + className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center" + placeholder="کد تصویر" + maxLength={6} + /> +
+
+ + )} + + {error &&

{error}

} + +
+ {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
+
+ +

+ حساب دارید؟{" "} + + ورود + +

+
+ ); +} diff --git a/frontend/src/components/skill-tree.tsx b/frontend/src/components/skill-tree.tsx new file mode 100644 index 0000000..f2fa626 --- /dev/null +++ b/frontend/src/components/skill-tree.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { + Book, + BookOpenCheck, + CheckCircle2, + ChevronDown, + ChevronLeft, + Cpu, + Flag, + Globe, + Layers, + Lock, + PlayCircle, + Rocket, + RotateCcw, + Target, + Terminal, + Zap, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import type { TreeChapter, TreeData, TreeLesson } from "@/lib/types"; +import { cn, formatFaNumber } from "@/lib/utils"; + +const CHAPTER_ICONS: Record = { + terminal: Terminal, + chip: Cpu, + rocket: Rocket, + book: Book, + layers: Layers, + flag: Flag, + target: Target, + zap: Zap, + code: Terminal, + globe: Globe, +}; + +export function SkillTree({ data, onSelect }: { data: TreeData; onSelect?: (id: number) => void }) { + const router = useRouter(); + + const chapters = useMemo(() => [...data.chapters].sort((a, b) => a.sortOrder - b.sortOrder), [data]); + const byId = useMemo(() => { + const map = new Map(); + chapters.forEach((ch) => (ch.lessons ?? []).forEach((l) => map.set(l.id, l))); + return map; + }, [chapters]); + + const all = useMemo(() => chapters.flatMap((ch) => ch.lessons ?? []), [chapters]); + const completed = all.filter((l) => l.progress?.passedQuiz).length; + + // system default: chapters with remaining actionable lessons are open + const [expanded, setExpanded] = useState>(() => { + const s = new Set(); + chapters.forEach((ch) => { + if ((ch.lessons ?? []).some((l) => !l.locked && !l.progress?.passedQuiz)) s.add(ch.id); + }); + return s; + }); + + const toggle = (id: number) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( +
+
+
+ + + +
+

نقشهٔ مسیر

+

+ {completed}/{all.length} درس تکمیل‌شده · {all.length - completed} باقی‌مانده +

+
+
+
+
+
+
+ +
+ {chapters.map((ch) => ( + toggle(ch.id)} + onSelect={onSelect ?? ((id: number) => router.push(`/player/${id}`))} + /> + ))} +
+
+ ); +} + +function ChapterRow({ + ch, + byId, + open, + onToggle, + onSelect, +}: { + ch: TreeChapter; + byId: Map; + open: boolean; + onToggle: () => void; + onSelect: (id: number) => void; +}) { + const lessons = [...(ch.lessons ?? [])].sort((a, b) => a.y - b.y || a.x - b.x); + const done = lessons.filter((l) => l.progress?.passedQuiz).length; + const isComplete = done === lessons.length; + const Icon = CHAPTER_ICONS[ch.icon] ?? Terminal; + + return ( +
+ + +
+
+
+ {lessons.map((lesson) => ( + + ))} +
+
+
+
+ ); +} + +function LessonRow({ + lesson, + byId, + onSelect, +}: { + lesson: TreeLesson; + byId: Map; + onSelect: (id: number) => void; +}) { + const passed = !!lesson.progress?.passedQuiz; + const locked = lesson.locked; + const inProgress = !locked && !passed && (lesson.progress?.watchedPct ?? 0) > 0; + const ready = !locked && !passed && !!lesson.progress?.quizUnlocked; + const retry = lesson.quizStatus === "failed"; + const pct = lesson.progress?.watchedPct ?? 0; + + const prereq = lesson.requiresLessonId ? byId.get(lesson.requiresLessonId) : undefined; + + const s = locked + ? { + row: "border-dashed border-border/80 bg-muted/30", + chip: "bg-muted text-muted-foreground", + pill: "bg-foreground/5 text-muted-foreground", + pillText: "قفل", + muted: true, + } + : passed + ? { row: "border-success/50 bg-success/5", chip: "bg-success text-success-foreground", pill: "bg-success/10 text-success", pillText: "تکمیل شده", muted: false } + : retry + ? { row: "border-accent/50 bg-accent/5", chip: "bg-accent text-accent-foreground", pill: "bg-accent/10 text-accent", pillText: "تلاش مجدد آزمون", muted: false } + : ready + ? { row: "border-primary/60 bg-primary/5", chip: "bg-primary text-primary-foreground", pill: "bg-gold/10 text-gold", pillText: "آمادهٔ آزمون", muted: false } + : inProgress + ? { row: "border-primary/40 bg-card", chip: "bg-primary/10 text-primary", pill: "bg-primary/10 text-primary", pillText: `${Math.round(pct)}٪ تماشا شده`, muted: false } + : { row: "border-border bg-card", chip: "bg-muted text-foreground", pill: "bg-muted/70 text-muted-foreground", pillText: `${formatFaNumber(lesson.durationSeconds)} ثانیه · ${formatFaNumber(lesson.xpReward)} امتیاز`, muted: false }; + + return ( +
{ + if (!locked) onSelect(lesson.id); + }} + onKeyDown={(e) => { + if (locked) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(lesson.id); + } + }} + > + + {(ready || retry) && } + + {locked ? : passed ? : retry ? : ready ? : } + + + +
+
+

{lesson.title}

+ {!locked && !passed && ( + +{formatFaNumber(lesson.xpReward)} امتیاز + )} +
+
+ {s.pillText} + {inProgress && ( +
+
+
+ )} + {locked && prereq && ( + + بعد از تکمیل «{prereq.title}» + + )} +
+
+ + {locked ? ( + + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/video-player.tsx b/frontend/src/components/video-player.tsx new file mode 100644 index 0000000..8637dd0 --- /dev/null +++ b/frontend/src/components/video-player.tsx @@ -0,0 +1,594 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { + Lock, + Pause, + Play, + RotateCcw, + SkipBack, + Volume2, + VolumeX, +} from "lucide-react"; +import { motion } from "framer-motion"; +import { api, http, toUserError } from "@/lib/api"; +import type { Lesson, LessonProgress } from "@/lib/types"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/providers"; + +const INTERVAL_MS = 10_000; +const SEEK_FORWARD_SLACK = 0.35; +const CONTINUOUS_PLAY_MAX_DT = 3; + +export function VideoPlayer({ + lessonId, + videoUrl, + duration, + completionThresholdPct, + initialProgress, +}: { + lessonId: number; + videoUrl: string; + duration: number; + completionThresholdPct: number; + initialProgress?: LessonProgress | null; +}) { + const videoRef = useRef(null); + // Lock first signed URL for this mount so React Query refetch cannot reset src. + const lockedSrcRef = useRef(videoUrl); + const resumeAppliedRef = useRef(false); + const accRef = useRef(0); + const lastTimeRef = useRef(0); + /** Furthest continuous playback position — forward seeks snap back to this. */ + const maxTrustedPosRef = useRef(0); + const seekToastAtRef = useRef(0); + const seqRef = useRef(0); + const programmaticSeekRef = useRef(false); + const refreshingSrcRef = useRef(false); + const sendHeartbeatRef = useRef<(pos: number, delta: number) => Promise>(async () => undefined); + + const [playing, setPlaying] = useState(false); + const [muted, setMuted] = useState(false); + const [position, setPosition] = useState(0); + const [watchedPct, setWatchedPct] = useState(initialProgress?.watchedPct ?? 0); + const [quizUnlocked, setQuizUnlocked] = useState(!!initialProgress?.quizUnlocked); + const [quizUnlockedNow, setQuizUnlockedNow] = useState(false); + const [loading, setLoading] = useState(true); + const [buffering, setBuffering] = useState(false); + const [loadError, setLoadError] = useState(null); + const [uiIdle, setUiIdle] = useState(false); + const [mediaDuration, setMediaDuration] = useState(duration); + const hideTimer = useRef(null); + + const warnSeekBlocked = useCallback(() => { + const now = Date.now(); + if (now - seekToastAtRef.current < 2500) return; + seekToastAtRef.current = now; + toast.error("پرش سریع و نمایش شناور مجاز نیست"); + }, []); + + const flushAcc = useCallback(() => { + const v = videoRef.current; + if (!v) return; + const delta = accRef.current; + accRef.current = 0; + if (delta < 0.05) return; + void sendHeartbeatRef.current(v.currentTime, delta); + }, []); + + const seekProgrammatic = useCallback((to: number) => { + const v = videoRef.current; + if (!v) return; + programmaticSeekRef.current = true; + v.currentTime = Math.max(0, to); + lastTimeRef.current = v.currentTime; + setPosition(v.currentTime); + window.setTimeout(() => { + programmaticSeekRef.current = false; + }, 120); + }, []); + + // resume once, only if still near the start (don't yank mid-play) + useEffect(() => { + let cancelled = false; + resumeAppliedRef.current = false; + (async () => { + try { + const { position: p, lastSeq } = await api<{ position: number; lastSeq?: number }>( + `/api/lessons/${lessonId}/resume` + ); + if (typeof lastSeq === "number" && lastSeq > seqRef.current) { + seqRef.current = lastSeq; + } + if (cancelled || resumeAppliedRef.current || !(p > 1)) return; + const apply = () => { + const v = videoRef.current; + if (!v || resumeAppliedRef.current) return; + if (v.currentTime >= 1) { + resumeAppliedRef.current = true; + return; + } + const dur = Number.isFinite(v.duration) && v.duration > 0 ? v.duration : duration; + if (p < dur - 2) { + programmaticSeekRef.current = true; + v.currentTime = p; + setPosition(p); + lastTimeRef.current = p; + maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, p); + window.setTimeout(() => { + programmaticSeekRef.current = false; + }, 120); + } + resumeAppliedRef.current = true; + }; + if (videoRef.current) { + if (videoRef.current.readyState >= 1) apply(); + else videoRef.current.addEventListener("loadedmetadata", apply, { once: true }); + } + } catch { + /* ignore */ + } + })(); + return () => { + cancelled = true; + }; + }, [lessonId, duration]); + + // Seed trusted position from known server progress so resume + scrub-back stay consistent. + useEffect(() => { + if (initialProgress?.lastPosition && initialProgress.lastPosition > 0) { + maxTrustedPosRef.current = Math.max(maxTrustedPosRef.current, initialProgress.lastPosition); + } + }, [initialProgress?.lastPosition]); + + const sendHeartbeat = useCallback( + async (pos: number, delta: number) => { + seqRef.current += 1; + try { + const res = await api<{ watchedPct: number; quizUnlocked: boolean; quizUnlockedNow: boolean }>( + `/api/lessons/${lessonId}/heartbeat`, + { method: "POST", body: { position: pos, delta, seq: seqRef.current } } + ); + setWatchedPct(res.watchedPct); + if (res.quizUnlocked) { + setQuizUnlocked(true); + } + if (res.quizUnlockedNow) { + setQuizUnlockedNow(true); + toast.success("آزمون باز شد! می‌توانید آموخته‌هایتان را محک بزنید."); + } + } catch (e) { + const msg = (e as Error)?.message ?? ""; + if (msg.includes("locked") || msg.includes("قفل")) { + toast.error(msg); + return; + } + toast.error(toUserError(e, "همگام‌سازی تماشا قطع شد — اتصال را بررسی کنید")); + } + }, + [lessonId] + ); + + useEffect(() => { + sendHeartbeatRef.current = sendHeartbeat; + }, [sendHeartbeat]); + + // anti-cheat: real playback only, lock rate/PiP, snap forward seeks + useEffect(() => { + const v = videoRef.current; + if (!v) return; + + v.disablePictureInPicture = true; + v.disableRemotePlayback = true; + v.playbackRate = 1; + v.defaultPlaybackRate = 1; + + const lockRate = () => { + if (v.playbackRate !== 1) { + v.playbackRate = 1; + warnSeekBlocked(); + } + if (v.defaultPlaybackRate !== 1) { + v.defaultPlaybackRate = 1; + } + }; + + const snapForwardSeek = () => { + if (programmaticSeekRef.current) return false; + const max = maxTrustedPosRef.current; + if (v.currentTime > max + SEEK_FORWARD_SLACK) { + programmaticSeekRef.current = true; + v.currentTime = max; + lastTimeRef.current = max; + setPosition(max); + window.setTimeout(() => { + programmaticSeekRef.current = false; + }, 120); + warnSeekBlocked(); + return true; + } + return false; + }; + + const onTimeUpdate = () => { + lockRate(); + if (snapForwardSeek()) return; + const dt = v.currentTime - lastTimeRef.current; + if (dt > 0 && dt < CONTINUOUS_PLAY_MAX_DT) { + accRef.current += dt; + if (v.currentTime > maxTrustedPosRef.current) { + maxTrustedPosRef.current = v.currentTime; + } + } + lastTimeRef.current = v.currentTime; + setPosition(v.currentTime); + }; + + const onEnded = () => { + setPlaying(false); + const delta = accRef.current; + accRef.current = 0; + void sendHeartbeatRef.current(v.currentTime, delta); + }; + + const blockPiP = () => { + void (async () => { + try { + if (document.pictureInPictureElement === v) { + await document.exitPictureInPicture(); + } + } catch { + /* ignore */ + } + v.pause(); + warnSeekBlocked(); + })(); + }; + + v.addEventListener("timeupdate", onTimeUpdate); + v.addEventListener("ended", onEnded); + v.addEventListener("seeking", snapForwardSeek); + v.addEventListener("seeked", snapForwardSeek); + v.addEventListener("ratechange", lockRate); + v.addEventListener("enterpictureinpicture", blockPiP); + return () => { + v.removeEventListener("timeupdate", onTimeUpdate); + v.removeEventListener("ended", onEnded); + v.removeEventListener("seeking", snapForwardSeek); + v.removeEventListener("seeked", snapForwardSeek); + v.removeEventListener("ratechange", lockRate); + v.removeEventListener("enterpictureinpicture", blockPiP); + }; + }, [lessonId, warnSeekBlocked]); + + // periodic verified heartbeats + visibility flush + useEffect(() => { + const v = videoRef.current; + if (!v) return; + + const sendPending = (opts?: { evenIfPaused?: boolean }) => { + const delta = accRef.current; + if (delta < 0.05) return; + if (!opts?.evenIfPaused && (v.paused || v.ended)) return; + accRef.current = 0; + void sendHeartbeatRef.current(v.currentTime, delta); + }; + + const iv = window.setInterval(() => sendPending(), INTERVAL_MS); + const flush = () => sendPending({ evenIfPaused: true }); + const onPause = () => sendPending({ evenIfPaused: true }); + const vis = () => { + if (document.hidden) { + sendPending({ evenIfPaused: true }); + if (!v.paused) v.pause(); + } + if (document.pictureInPictureElement === v) { + void document.exitPictureInPicture().catch(() => undefined); + v.pause(); + } + }; + v.addEventListener("pause", onPause); + document.addEventListener("visibilitychange", vis); + window.addEventListener("beforeunload", flush); + window.addEventListener("pagehide", flush); + return () => { + window.clearInterval(iv); + v.removeEventListener("pause", onPause); + document.removeEventListener("visibilitychange", vis); + window.removeEventListener("beforeunload", flush); + window.removeEventListener("pagehide", flush); + flush(); + }; + }, [lessonId]); + + useEffect(() => { + const v = videoRef.current; + if (v) v.muted = muted; + }, [muted]); + + const refreshSignedUrl = useCallback(async () => { + if (refreshingSrcRef.current) return; + refreshingSrcRef.current = true; + try { + const data = await http.get<{ lesson: Lesson; videoUrl: string; progress: LessonProgress | null }>( + `/api/lessons/${lessonId}` + ); + if (!data.videoUrl) { + setLoadError("ویدیوی این درس در دسترس نیست."); + return; + } + lockedSrcRef.current = data.videoUrl; + const v = videoRef.current; + if (v) { + const keep = v.currentTime; + const wasPlaying = !v.paused; + v.src = data.videoUrl; + v.load(); + const restore = () => { + if (keep > 0) { + programmaticSeekRef.current = true; + v.currentTime = keep; + lastTimeRef.current = keep; + window.setTimeout(() => { + programmaticSeekRef.current = false; + }, 120); + } + if (wasPlaying) void v.play().catch(() => undefined); + }; + v.addEventListener("loadedmetadata", restore, { once: true }); + } + if (data.progress) { + setWatchedPct(data.progress.watchedPct); + if (data.progress.quizUnlocked) setQuizUnlocked(true); + } + setLoadError(null); + setLoading(true); + } catch { + setLoadError("بارگذاری مجدد ویدیو ممکن نشد. دوباره تلاش کنید."); + } finally { + refreshingSrcRef.current = false; + } + }, [lessonId]); + + const toggle = () => { + const v = videoRef.current; + if (!v) return; + if (v.paused) void v.play().catch(() => toast.error("پخش ویدیو ممکن نشد")); + else v.pause(); + }; + + const onPlayChange = useCallback(() => { + const v = videoRef.current; + setPlaying(!!v && !v.paused); + }, []); + + const poke = useCallback(() => { + setUiIdle(false); + if (hideTimer.current) window.clearTimeout(hideTimer.current); + const v = videoRef.current; + if (v && !v.paused && !v.ended) { + hideTimer.current = window.setTimeout(() => setUiIdle(true), 2600); + } + }, []); + + const skipBack = () => { + const v = videoRef.current; + if (!v) return; + flushAcc(); + seekProgrammatic(Math.max(0, v.currentTime - 10)); + poke(); + }; + + const restartFromStart = () => { + const v = videoRef.current; + if (!v) return; + flushAcc(); + seekProgrammatic(0); + void v.play().catch(() => undefined); + poke(); + }; + + useEffect(() => { + if (playing) poke(); + return () => { + if (hideTimer.current) window.clearTimeout(hideTimer.current); + }; + }, [playing, poke]); + + const pct = mediaDuration > 0 ? Math.min(100, (position / mediaDuration) * 100) : 0; + const fmt = (s: number) => { + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, "0")}`; + }; + + return ( +
+
+
+ ); +}