feat(frontend): add learning, community, and profile feature components

Cover verified playback, quizzes, skill tree, certificates, events, league, and registration UI.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent 680db7dce0
commit 5b4936903a
15 changed files with 3286 additions and 0 deletions
+204
View File
@@ -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> | void;
}) {
const boxRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
(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<HTMLDivElement>) => {
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<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
if (blob) await onCrop(blob);
};
const zoomPct = Math.round((scale / maxScale) * 100);
return (
<Dialog open={!!file && !!img} onOpenChange={(o) => !o && !busy && onCancel()}>
<DialogContent className="max-w-sm rounded-2xl">
<DialogHeader>
<DialogTitle>تنظیم عکس آواتار</DialogTitle>
</DialogHeader>
<div className="relative mx-auto aspect-square w-full max-w-[280px]" ref={boxRef}>
{img ? (
<>
<div
className="absolute inset-0 touch-none select-none overflow-hidden rounded-full ring-2 ring-border"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
style={{ cursor: "move" }}
>
<img
src={img.el.src}
alt="پیش‌نمایش آواتار"
draggable={false}
className="pointer-events-none select-none"
style={{
position: "absolute",
left: 0,
top: 0,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "0 0",
width: img.w,
height: img.h,
maxWidth: "none",
maxHeight: "none",
}}
/>
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background: "rgba(0,0,0,0.45)",
WebkitMaskImage: "radial-gradient(circle at center, transparent 49.2%, black 50%)",
maskImage: "radial-gradient(circle at center, transparent 49.2%, black 50%)",
}}
/>
</div>
<span className="pointer-events-none absolute inset-0 rounded-full ring-2 ring-primary/70 ring-offset-2 ring-offset-background" />
</>
) : (
<div className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
type="range"
min={17}
max={100}
value={zoomPct}
onChange={(e) => zoom((Number(e.target.value) / 100) * maxScale)}
aria-label="بزرگ‌نمایی"
className="w-full accent-primary"
/>
</div>
<DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-between">
<Button variant="outline" onClick={onCancel} disabled={busy}>
<X /> انصراف
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={setZoomIn} disabled={busy} title="تنظیم خودکار">
<Scan /> برازش
</Button>
<Button onClick={confirm} disabled={busy || !img}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check />}
{busy ? "در حال آپلود…" : "تأیید و ذخیره"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+293
View File
@@ -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<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<File | null>(null);
const [draftV, setDraftV] = useState<string>(user.avatarVariant ?? "beam");
const [draftP, setDraftP] = useState<string>(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 (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative shrink-0">
<UserAvatar
name={user.name ?? "؟"}
src={photo}
variant={draftV as User["avatarVariant"]}
palette={avatarPaletteOf(draftP)}
className="h-20 w-20"
/>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 grid h-6 w-6 place-items-center rounded-full border-2 border-popover",
photo ? "bg-destructive text-destructive-foreground" : "bg-primary text-primary-foreground"
)}
>
{photo ? <span className="h-2 w-2 rounded-full bg-current" /> : <Camera className="h-3.5 w-3.5" />}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-extrabold">{user.name}</p>
<p className="text-[11px] text-muted-foreground">پیش‌نمایش زنده — تغییرات تا ذخیره اعمال نشده است</p>
{dirty && (
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-bold text-accent">
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
تغییرات ذخیره‌نشده
</span>
)}
</div>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">سبک</p>
<div className="grid grid-cols-6 gap-1.5">
{AVATAR_VARIANTS.map((v) => (
<button
key={v.name}
title={v.label}
onClick={() => setDraftV(v.name)}
className={cn(
"flex flex-col items-center gap-1 rounded-lg p-1.5 transition-colors",
draftV === v.name ? "bg-primary/15 ring-2 ring-primary" : "hover:bg-muted"
)}
>
<UserAvatar name={user.name + v.name} variant={v.name} palette={avatarPaletteOf(draftP)} className="h-7 w-7" />
<span className={cn("text-[9px]", draftV === v.name ? "text-primary" : "text-muted-foreground")}>
{v.label}
</span>
</button>
))}
</div>
</div>
<div>
<p className="mb-1.5 text-[11px] font-bold text-muted-foreground">رنگ‌ها</p>
<div className="space-y-1">
{PALETTES.map((p) => {
const active = JSON.stringify(p.colors) === draftP;
return (
<button
key={p.label}
onClick={() => setDraftP(JSON.stringify(p.colors))}
className={cn(
"flex w-full items-center justify-between rounded-lg border px-2.5 py-1.5 transition-colors",
active ? "border-primary bg-primary/10" : "border-border hover:bg-muted"
)}
>
<span className="text-xs font-bold">{p.label}</span>
<span className="flex items-center gap-0.5">
{p.colors.slice(0, 5).map((c) => (
<span
key={c}
className="h-3.5 w-3.5 rounded-full ring-1 ring-black/10 dark:ring-white/10"
style={{ background: c }}
/>
))}
{active && <Check className="ms-1 h-3.5 w-3.5 text-primary" />}
</span>
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => fileRef.current?.click()}
disabled={busy}
className="press flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-2 py-1.5 text-xs font-bold text-primary-foreground disabled:opacity-50"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Camera className="h-3.5 w-3.5" />}
{photo ? "تغییر عکس" : "آپلود عکس"}
</button>
{photo && (
<button
onClick={() => doSave({ avatarPhoto: "" })}
disabled={busy}
className="press flex items-center justify-center gap-1.5 rounded-lg bg-destructive/10 px-2 py-1.5 text-xs font-bold text-destructive disabled:opacity-50"
title="حذف عکس و استفاده از آواتار گرافیکی"
>
<Trash2 className="h-3.5 w-3.5" />
حذف عکس
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) setPending(f);
e.target.value = "";
}}
/>
</div>
{error && <p className="text-[11px] font-bold text-destructive">{error}</p>}
<div className="sticky -bottom-3 z-10 -mx-2.5 flex items-center justify-between gap-2 bg-popover px-2.5 pb-1 pt-2">
<div className="flex items-center gap-2">
{dirty && (
<Button variant="ghost" size="sm" onClick={reset} disabled={busy}>
<RotateCcw /> بازنشانی
</Button>
)}
</div>
<div className="flex items-center gap-2">
{dirty ? (
<Button onClick={onSave} disabled={busy} size="sm">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save />}
ذخیره تغییرات
</Button>
) : (
<Button variant="outline" size="sm" onClick={onClose}>
<Check /> اتمام
</Button>
)}
</div>
</div>
<AvatarCropDialog
file={pending}
busy={busy}
onCancel={() => !busy && setPending(null)}
onCrop={(blob) => onUpload(blob)}
/>
<Dialog
open={confirming}
onOpenChange={(o) => !o && setConfirming(false)}
>
<DialogContent className="max-w-sm gap-4 rounded-2xl">
<DialogHeader>
<DialogTitle>حذف عکس؟</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center gap-3">
<UserAvatar name={user.name} src={photo} className="h-12 w-12" />
<span className="text-muted-foreground">
<ArrowLeft className="h-4 w-4" />
</span>
<UserAvatar
name={user.name + "x"}
variant={draftV as User["avatarVariant"]}
palette={avatarPaletteOf(draftP)}
className="h-12 w-12"
/>
</div>
<p className="text-sm text-muted-foreground">
برای اعمال این سبک، عکس فعلی آواتارت حذف می‌شود و آواتار گرافیکی جایگزین آن می‌شود. مطمئنی؟
</p>
<DialogFooter className="sm:space-x-0 sm:space-x-reverse sm:justify-start">
<Button variant="destructive" onClick={confirmReplace} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 />}
حذف عکس و اعمال سبک
</Button>
<Button variant="outline" onClick={() => setConfirming(false)} disabled={busy}>
انصراف
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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 (
<div className="relative mx-auto min-h-[70vh] max-w-2xl px-4 py-12">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background" />
<div className="rounded-2xl border-2 border-border bg-card p-8 shadow-soft sm:p-10">
<div className="flex flex-col items-center text-center">
<Award className="mb-4 h-12 w-12 text-primary" />
<BrandMark locale="fa" size="lg" />
<p className="section-kicker mt-6">گواهینامه پایان دوره</p>
<h1 className="mt-2 text-3xl font-extrabold tracking-tight">{cert.fullName}</h1>
<p className="mt-3 max-w-md text-sm leading-relaxed text-muted-foreground">
با موفقیت دوره {cert.course} را به پایان رسانده و همه درس‌های فعال را پاس کرده‌اند.
</p>
<p className="mt-6 text-xs text-muted-foreground">
تاریخ صدور: {new Date(cert.issuedAt).toLocaleDateString("fa-IR")}
</p>
<p className="mt-1 font-mono text-[11px] text-muted-foreground" dir="ltr">
تأیید: {cert.publicId}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => void copyLink()}>
<Copy className="h-3.5 w-3.5" /> کپی لینک
</Button>
{showOwnerLinks && (
<Button asChild variant="ghost" size="sm">
<Link href="/cap">درخت مهارت</Link>
</Button>
)}
</div>
</div>
{children}
</div>
</div>
);
}
@@ -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 (
<div className={cn("space-y-3", className)}>
<Skeleton className="h-28 rounded-xl" />
</div>
);
}
if (isError) {
return (
<div className={cn("rounded-xl border border-destructive/30 bg-destructive/5 p-4 text-sm", className)}>
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
تلاش دوباره
</Button>
</div>
);
}
const list = data?.challenges ?? [];
if (list.length === 0) return null;
return (
<div className={cn("space-y-3", className)}>
{list.map((c) => {
const pct = Math.min(100, Math.round((c.myXp / Math.max(c.targetXp, 1)) * 100));
return (
<div
key={c.id}
className={cn(
"rounded-xl border-2 border-border bg-card p-4 shadow-soft",
c.completed && "border-success/40 bg-success/5"
)}
>
<div className="flex items-start gap-3">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-gold/15 text-gold">
{c.completed ? <CheckCircle2 className="h-5 w-5 text-success" /> : <Trophy className="h-5 w-5" />}
</div>
<div className="min-w-0 flex-1">
<p className="font-extrabold">{c.title}</p>
{c.description && <p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>}
<div className="mt-3 space-y-1.5">
<div className="flex justify-between text-xs font-bold">
<span>
{formatFaNumber(c.myXp)} / {formatFaNumber(c.targetXp)} امتیاز
</span>
<span className={c.completed ? "text-success" : "text-muted-foreground"}>
{c.completed ? "تکمیل شد" : `${formatFaNumber(pct)}٪`}
</span>
</div>
<Progress value={pct} className="h-2" />
</div>
<p className="mt-2 text-[11px] text-muted-foreground">
تا{" "}
{new Date(c.endsAt).toLocaleString("fa-IR", {
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}
@@ -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<CommunityPayload>("/api/community"),
});
if (isLoading) {
return (
<div className="h-72 animate-pulse rounded-2xl border-2 border-border bg-muted/40" />
);
}
if (isError) {
return (
<div className="mx-auto max-w-xl rounded-2xl border-2 border-destructive/30 bg-destructive/5 p-8 text-center">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button className="mt-4" variant="outline" onClick={() => refetch()}>
تلاش دوباره
</Button>
</div>
);
}
const donation = data?.donation;
return (
<div className="mx-auto max-w-xl">
<Panel
title="حمایت مالی"
description="بدون کارت عمومی — اگر لاگین باشید، گفتگو با تیم باز می‌شود."
icon={<HeartHandshake className="h-5 w-5" />}
tint="bg-accent/15 text-accent"
>
{donation?.enabled ? (
<div className="space-y-4 pt-1">
<p className="text-sm leading-relaxed text-muted-foreground">{donation.note}</p>
<div className="rounded-2xl border border-border bg-muted/20 p-5 text-sm leading-relaxed">
جزئیات واریز فقط در پیام خصوصی رد و بدل می‌شود؛ اینجا چیزی عمومی نیست.
</div>
{user ? (
<Button
className="w-full gap-2"
size="lg"
onClick={() => openDonationChat(donation.contactId, donation.draft)}
>
<MessageSquareHeart className="h-4 w-4" /> پیام دربارهٔ دونیت
</Button>
) : (
<Button asChild className="w-full gap-2" size="lg">
<Link href="/login">
<LogIn className="h-4 w-4" /> ورود برای هماهنگی دونیت
</Link>
</Button>
)}
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">حمایت فعلاً غیرفعال است.</p>
)}
</Panel>
</div>
);
}
+207
View File
@@ -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 (
<div className="grid gap-4 sm:grid-cols-2">
{[0, 1].map((i) => (
<div key={i} className="space-y-4 rounded-2xl border-2 border-border bg-card p-5 shadow-soft">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<Skeleton className="h-4 w-56" />
<Skeleton className="h-3 w-40" />
<div className="flex items-center justify-between">
<div className="flex gap-1.5">
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-7 w-7 rounded-full" />
<Skeleton className="h-7 w-7 rounded-full" />
</div>
<Skeleton className="h-9 w-28 rounded-lg" />
</div>
</div>
))}
</div>
);
}
if (isError) {
return (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
تلاش دوباره
</Button>
</div>
);
}
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 (
<Panel
title="رویدادهای زنده"
description="پیش‌روها را ثبت‌نام کنید و لینک پیوستن را همان‌جا بیابید."
icon={<CalendarDays className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
className="space-y-0"
>
<div className="space-y-8">
{events.length === 0 && (
<EmptyState
icon={<CalendarDays className="h-6 w-6" />}
title="هنوز رویدادی برنامه‌ریزی نشده"
description="به‌زودی دوباره سر بزنید — یا از یک منتور بخواهید رویدادی برگزار کند."
/>
)}
{upcoming.length > 0 && (
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
<CalendarDays className="h-3.5 w-3.5 text-primary" />
</span>
<p className="text-xs font-bold text-foreground">پیش‌رو</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{upcoming.map((e) => (
<Card key={e.id} className={cn("group relative overflow-hidden", e.rsvped && "ring-1 ring-primary/50")}>
<div className={cn("absolute inset-x-0 top-0 h-1 bg-primary", !e.rsvped && "bg-muted")} />
<CardContent className="pt-5">
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge variant="accent" className="rounded-lg">
{eventTypeLabel(e.eventType)}
</Badge>
<span className="text-xs text-muted-foreground">{fmt(e.startsAt)}</span>
</div>
<h3 className="text-lg font-bold leading-snug">{e.title}</h3>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{e.description}</p>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{e.externalUrl && (
<a
href={e.externalUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" /> پیوستن
</a>
)}
<button
className="inline-flex items-center gap-1 hover:underline"
onClick={() => window.open(`/api/events/${e.id}/ics`)}
>
<CalendarPlus className="h-3.5 w-3.5" /> افزودن به تقویم
</button>
</div>
<div className="mt-4 flex justify-end">
<Button
size="sm"
variant={e.rsvped ? "outline" : "gradient"}
disabled={toggle.isPending}
onClick={() => toggle.mutate(e)}
>
{e.rsvped ? (
<>
<Check className="h-4 w-4" /> شرکت می‌کنم
</>
) : (
<>
<Users className="h-4 w-4" /> ثبت‌نام
</>
)}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{past.length > 0 && (
<div>
<div className="mb-3 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-muted">
<Flame className="h-3.5 w-3.5 text-muted-foreground" />
</span>
<p className="text-xs font-bold text-muted-foreground">گذشته</p>
</div>
<div className="space-y-2">
{past.map((e) => (
<div
key={e.id}
className="flex items-center justify-between rounded-xl border border-border bg-muted/40 px-4 py-3 text-sm opacity-80"
>
<div>
<p className="font-medium">{e.title}</p>
<p className="text-xs text-muted-foreground">{fmt(e.startsAt)}</p>
</div>
<Badge variant="outline" className="rounded-lg">{eventTypeLabel(e.eventType)}</Badge>
</div>
))}
</div>
</div>
)}
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3.5 w-3.5" /> همه رویدادها آنلاین هستند — لینک پیوستن در هر کارت است.
</p>
</div>
</Panel>
);
}
function eventTypeLabel(eventType: string): string {
if (eventType === "workshop") return "کارگاه";
if (eventType === "meet") return "جلسه";
if (eventType === "zoom") return "آنلاین";
return eventType;
}
+52
View File
@@ -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 (
<span
className={cn(
"inline-flex items-center gap-1 font-black text-primary",
size === "lg" ? "text-2xl" : size === "sm" ? "text-sm" : "text-lg"
)}
title="جان نامحدود"
aria-label="جان نامحدود"
>
<Heart className={cn(dims, "fill-rose-500 text-rose-500")} />
<InfinityIcon className={cn(dims, "text-primary")} strokeWidth={2.5} />
</span>
);
}
const n = clampHearts(hearts);
return (
<div className="flex items-center gap-1.5">
{Array.from({ length: MAX_HEARTS }, (_, i) => {
const on = i < n;
return (
<Heart
key={i}
className={cn(
dims,
"transition-transform duration-200",
on
? cn("fill-rose-500 text-rose-500", n === 1 && "animate-pulse-soft")
: "text-muted-foreground/40"
)}
/>
);
})}
</div>
);
}
@@ -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<LeaderboardData>("/api/leaderboard/weekly"),
});
const { resetLabel, nextResetJalali } = useResetCountdown();
if (isLoading) {
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-40 rounded-2xl" />
))}
</div>
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 rounded-xl" />
))}
</div>
);
}
if (isError) {
return (
<div className="mx-auto max-w-2xl rounded-xl border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button className="mt-3" variant="outline" onClick={() => refetch()}>
تلاش دوباره
</Button>
</div>
);
}
const entries = data?.entries ?? [];
const me = data?.me;
const podium = entries.slice(0, 3);
const rest = entries.slice(3);
return (
<div className="mx-auto max-w-2xl space-y-4">
<ChallengeCards />
<Panel
title={
<>
لیگ هفتگی · <span className="text-muted-foreground">{data?.week}</span>
</>
}
description="لیگ هفتگی جدا از چالش ماهانه است. رتبه‌بندی هر دوشنبهٔ میلادی (آغاز هفتهٔ ISO) صفر می‌شود."
icon={<Trophy className="h-5 w-5" />}
tint="bg-gold/10 text-gold"
>
<div className="space-y-6">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<span className="inline-flex w-fit items-center gap-1.5 rounded-lg bg-muted/80 px-3 py-1.5 text-xs font-bold text-muted-foreground">
<Timer className="h-3.5 w-3.5 shrink-0" />
ریست دوشنبه · {resetLabel}
</span>
<span className="inline-flex w-fit items-center rounded-lg bg-muted/60 px-3 py-1.5 text-[11px] font-medium text-muted-foreground">
معادل جلالی: {nextResetJalali}
</span>
{me && me.rank > 0 && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-bold text-primary">
<Crown className="h-3.5 w-3.5 shrink-0" />
رتبه {fa(me.rank)} شمایید
</span>
)}
</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">
<div className="flex flex-col items-center gap-1 text-center">
<span className="mb-1 grid h-10 w-10 place-items-center rounded-xl bg-gold/15 text-gold">
<Sparkles className="h-5 w-5" />
</span>
<p className="text-3xl font-black tabular-nums tracking-tight">
{formatFaNumber(Math.round(me?.xp ?? 0))}
</p>
<p className="text-sm font-bold text-muted-foreground">
{me && me.rank > 0 ? "امتیاز شما این هفته" : "هنوز امتیازی کسب نکرده‌اید — بروید به دست آورید"}
</p>
</div>
</div>
{podium.length > 0 && <Podium entries={podium} myId={user?.id} />}
<div className="space-y-1.5">
{rest.map((e) => {
const isMe = !!user && e.userId === user.id;
return (
<div
key={e.userId}
className={cn(
"flex items-center gap-3 rounded-xl border px-4 py-2.5",
isMe ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border bg-card"
)}
>
<span
className={cn(
"w-8 shrink-0 text-center text-sm font-bold",
isMe ? "text-primary" : "text-muted-foreground"
)}
>
{formatFaNumber(e.rank)}
</span>
<UserAvatar name={e.name} className="h-8 w-8" {...avatarPropsOf(e)} />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{e.name}</span>
{isMe && (
<span className="hidden shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-extrabold text-primary sm:inline">
شما
</span>
)}
<span className="shrink-0 text-sm font-bold text-gold">
{formatFaNumber(Math.round(e.xp))} امتیاز
</span>
</div>
);
})}
{rest.length === 0 && entries.length === 0 && (
<EmptyState
icon={<Trophy className="h-6 w-6" />}
title="هنوز رتبه‌بندی وجود ندارد"
description="با تکمیل صادقانه درس‌ها امتیاز کسب کنید تا در لیگ بالا بروید."
/>
)}
</div>
</div>
</Panel>
</div>
);
}
function Podium({ entries, myId }: { entries: LeaderboardEntry[]; myId?: number }) {
if (entries.length === 1) {
return (
<div className="flex justify-center">
<PodiumCard entry={entries[0]!} tall myId={myId} className="w-full max-w-[11rem]" />
</div>
);
}
if (entries.length === 2) {
const ordered = [...entries].sort((a, b) => a.rank - b.rank);
return (
<div className="mx-auto grid max-w-md grid-cols-2 items-end gap-3">
{ordered.map((e) => (
<PodiumCard key={e.userId} entry={e} tall={e.rank === 1} myId={myId} />
))}
</div>
);
}
// Classic 2nd · 1st · 3rd visual order
return (
<div className="grid grid-cols-3 items-end gap-2 sm:gap-3">
{[1, 0, 2].map((idx) => {
const e = entries[idx];
if (!e) return <div key={idx} />;
return <PodiumCard key={e.userId} entry={e} tall={e.rank === 1} myId={myId} />;
})}
</div>
);
}
function PodiumCard({
entry,
tall,
myId,
className,
}: {
entry: LeaderboardEntry;
tall?: boolean;
myId?: number;
className?: string;
}) {
const isMe = myId != null && entry.userId === myId;
return (
<div
className={cn(
"flex flex-col items-center rounded-2xl border-2 border-border bg-card px-3 py-4 text-center shadow-offset-sm",
tall ? "pb-6 pt-5" : "pb-4",
isMe && "border-primary/50 ring-2 ring-primary/30",
className
)}
>
<UserAvatar
name={entry.name}
className={cn(
"mb-2 h-12 w-12",
tall && "h-14 w-14 ring-2 ring-gold ring-offset-2 ring-offset-background"
)}
{...avatarPropsOf(entry)}
/>
<p className="w-full truncate text-sm font-extrabold">{entry.name}</p>
<p className="mt-0.5 text-xs font-medium text-muted-foreground">
{formatFaNumber(Math.round(entry.xp))} امتیاز
</p>
<div
className={cn(
"mt-2 flex h-8 w-8 items-center justify-center rounded-lg text-sm font-black",
medalClasses[entry.rank as 1 | 2 | 3] ?? "bg-muted text-muted-foreground"
)}
>
{entry.rank === 1 ? <Crown className="h-4 w-4" /> : formatFaNumber(entry.rank)}
</div>
</div>
);
}
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]);
}
@@ -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<string, string> = {
progress: "bg-primary",
gamification: "bg-gold",
mentor: "bg-accent",
event: "bg-secondary",
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative rounded-md" aria-label="اعلان‌ها">
<Bell className="h-[18px] w-[18px]" />
{unread > 0 && (
<span className="absolute -end-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground">
{unread > 9 ? "9+" : unread}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between rounded-t-md border-b border-border bg-muted/40 px-3 py-2.5">
<span className="text-sm font-semibold">اعلان‌ها</span>
<div className="flex items-center gap-1">
{unread > 0 && <Badge variant="destructive" className="px-2 py-0">{unread} جدید</Badge>}
<Button variant="ghost" size="sm" onClick={markAll} className="h-7 gap-1 px-2 text-xs">
<CheckCheck className="h-3.5 w-3.5" /> خواندن همه
</Button>
</div>
</div>
<div className="max-h-[360px] overflow-y-auto">
{isLoading && (
<div className="space-y-3 px-3 py-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="mt-1.5 h-2 w-2 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
)}
{!isLoading && (!data?.notifications || data.notifications.length === 0) && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
اعلانی ندارید.
<br />
<span className="text-xs">با قبولی در یک آزمون یا ثبت‌نام در رویداد، اعلان می‌گیرید.</span>
</div>
)}
{data?.notifications.map((n) => (
<button
key={n.id}
onClick={() => {
if (n.route) router.push(n.route);
}}
className={cn(
"flex w-full items-start gap-3 border-b border-border px-3 py-3 text-right transition-colors hover:bg-muted/60",
!n.readAt && "bg-primary/5"
)}
>
<span className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", categoryDot[n.category] ?? "bg-primary")} />
<span className="flex-1 space-y-0.5">
<span className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{n.title}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
</span>
</span>
{n.body && <span className="block text-xs text-muted-foreground">{n.body}</span>}
</span>
</button>
))}
</div>
<button
onClick={() => router.push("/notifications")}
className="w-full rounded-b-md bg-muted/40 py-2.5 text-xs font-semibold text-primary hover:bg-muted"
>
مشاهده همه اعلان‌ها
</button>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -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 (
<div className="mx-auto max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{data?.unread ? `${data.unread} خوانده‌نشده` : "همه‌چیز به‌روز است"} · {notifications.length} اعلان اخیر
</p>
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => markAll.mutate()} disabled={!data?.unread}>
<CheckCheck className="h-4 w-4" /> خواندن همه
</Button>
</div>
{isLoading && (
<div className="space-y-2 py-2">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 rounded-xl" />
))}
</div>
)}
{!isLoading && notifications.length === 0 && (
<EmptyState
icon={<Inbox className="h-6 w-6" />}
title="هنوز اعلانی نیست"
description="پیشرفت، زنجیرهٔ فعالیت، یادآوری ثبت‌نام و پاسخ منتور اینجا می‌آیند."
/>
)}
<div className="space-y-2">
{notifications.map((n) => {
const cat = categoryMap[n.category] ?? { label: n.category, dot: "bg-primary", classes: "bg-muted text-muted-foreground" };
return (
<button
key={n.id}
onClick={() => {
if (n.route) router.push(n.route);
}}
className={cn(
"flex w-full items-start gap-3 rounded-xl border px-4 py-3.5 text-right transition-colors hover:border-primary/30 hover:bg-muted/40",
!n.readAt ? "border-primary/30 bg-primary/5" : "border-border bg-card"
)}
>
<span className={cn("mt-2 h-2.5 w-2.5 shrink-0 rounded-full", cat.dot)} />
<span className="flex-1 space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{n.title}</span>
<Badge variant="outline" className={cn("px-1.5 py-0 text-[10px] rounded-lg", cat.classes)}>
{cat.label}
</Badge>
</span>
{n.body && <span className="block text-sm text-muted-foreground">{n.body}</span>}
<span className="block text-[11px] text-muted-foreground/70">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true, locale: faIR })}
</span>
</span>
</button>
);
})}
</div>
</div>
);
}
@@ -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<string, string> = {
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<MineCertificateResponse>("/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 (
<section
aria-label="جایگاه گواهینامه"
className="rounded-xl border border-dashed border-border bg-muted/20 p-3"
>
<div className="mb-2 flex items-center gap-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary/10 text-primary">
<Award className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-extrabold">گواهینامه پایان دوره</p>
<p className="text-[10px] text-muted-foreground">جایگاه ثابت گواهی شما در پروفایل</p>
</div>
</div>
{isError ? (
<div className="space-y-2 py-2 text-xs">
<p className="font-medium text-destructive">{toUserError(error)}</p>
<Button type="button" size="sm" variant="outline" className="h-8" onClick={() => refetch()}>
تلاش دوباره
</Button>
</div>
) : isLoading || (eligible && !cert && issuing) ? (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
در حال بارگذاری…
</div>
) : cert ? (
<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>
<p className="mt-0.5 text-[11px] text-muted-foreground">
صادر شده در {new Date(cert.issuedAt).toLocaleDateString("fa-IR")}
</p>
<p className="mt-1 truncate font-mono text-[10px] text-muted-foreground" dir="ltr">
{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" />
مشاهده
</Button>
<Button type="button" size="sm" variant="outline" className="h-8" onClick={() => void copyLink()}>
<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 ? (
<div className="space-y-2.5">
<p className="text-[11px] leading-relaxed text-muted-foreground">
دوره را تمام کرده‌اید. گواهینامه دیجیتال آمادهٔ صدور است.
</p>
<Button type="button" size="sm" className="h-8 w-full" disabled={issuing} onClick={() => void claim()}>
{issuing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
دریافت گواهینامه
</Button>
</div>
) : (
<div
className={cn(
"flex items-start gap-2 rounded-lg border border-border/70 bg-background/60 px-3 py-2.5",
)}
>
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<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>
)}
</section>
);
}
+312
View File
@@ -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<Record<number, number>>({});
const [result, setResult] = useState<QuizResult | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data, isLoading, isError, error: loadError } = useQuery({
queryKey: ["quiz", lessonId],
queryFn: () => http.get<QuizData>(`/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<QuizResult>(`/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 (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="w-full max-w-xl space-y-4">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-4 w-96 max-w-full" />
<div className="rounded-2xl border-2 border-border bg-card p-6 shadow-soft">
<Skeleton className="h-5 w-2/3" />
<div className="mt-6 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-xl" />
))}
</div>
<Skeleton className="mt-6 h-11 w-full rounded-xl" />
</div>
</div>
</div>
);
}
if (isError || (loadError && !data)) {
const msg = toUserError(
loadError,
error ?? "بارگذاری آزمون ممکن نشد. ابتدا درس را تا آستانه تماشا کنید.",
);
return (
<Card className="mx-auto max-w-md p-8 text-center">
<Lock className="mx-auto mb-3 h-10 w-10 text-destructive" />
<h2 className="mb-2 text-lg font-semibold">آزمون در دسترس نیست</h2>
<p className="mb-4 text-sm text-muted-foreground">{msg}</p>
<Button onClick={() => router.push(`/player/${lessonId}`)}>ابتدا درس را تماشا کنید</Button>
</Card>
);
}
if (data?.passed) {
return (
<Card className="mx-auto max-w-md p-10 text-center">
<CheckCircle2 className="mx-auto mb-3 h-12 w-12 text-success" />
<h2 className="text-2xl font-bold text-primary">درس تکمیل شده</h2>
<p className="mt-1 text-sm text-muted-foreground">شما قبلاً این آزمون را با موفقیت گذرانده‌اید.</p>
<div className="mt-6 flex justify-center gap-2">
<Button variant="outline" onClick={() => router.push("/cap")}>
<ArrowLeft className="h-4 w-4" /> درخت مهارت
</Button>
<Button variant="gradient" onClick={() => router.push("/leaderboard")}>
لیگ‌ها
</Button>
</div>
</Card>
);
}
const infiniteHearts = hasInfiniteHearts(liveUser?.role);
if (result) {
return (
<ResultScreen
result={result}
lessonId={lessonId}
infiniteHearts={infiniteHearts}
onRetake={() => {
setResult(null);
setAnswers({});
qc.invalidateQueries({ queryKey: ["quiz", lessonId] });
}}
/>
);
}
return (
<div className="mx-auto max-w-2xl space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-primary">آزمون دروس</p>
<h1 className="text-2xl font-bold">آموخته‌هایتان را ثابت کنید</h1>
</div>
<HeartsMeter hearts={liveUser?.hearts ?? 3} size="md" infinite={infiniteHearts} />
</div>
{!infiniteHearts && (
<p className="-mt-3 text-sm text-muted-foreground">
هر پاسخ اشتباه یک قلب کم می‌کند. با از دست دادن هر سه قلب، حساب محدود می‌شود و ادامهٔ مسیر
فقط پس از بررسی مدیریت ممکن است.
</p>
)}
{infiniteHearts && (
<p className="-mt-3 text-sm text-muted-foreground">جان شما نامحدود است — می‌توانید بدون محدودیت آزمون را تکرار کنید.</p>
)}
<div className="space-y-4">
{questions.map((q, qi) => (
<Card key={q.id} className="overflow-hidden">
<CardContent className="pt-5">
<div className="mb-3 flex items-center gap-2">
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
{qi + 1}
</Badge>
<p className="font-medium leading-snug">{q.question}</p>
</div>
<div className="grid gap-2">
{q.options.map((opt, oi) => {
const active = answers[q.id] === oi;
return (
<button
key={oi}
onClick={() => select(q.id, oi)}
className={cn(
"flex items-center gap-3 rounded-md border px-4 py-3 text-right text-sm transition-colors",
active
? "border-primary bg-primary/10 ring-1 ring-primary"
: "border-border hover:bg-muted/50"
)}
>
<span
className={cn(
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
active ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground"
)}
>
{formatOptionLetter(oi)}
</span>
{opt}
</button>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="h-4 w-4" /> {error}
</div>
)}
<div className="flex items-center justify-between gap-4 pt-2">
<p className="text-sm text-muted-foreground">
{answeredCount}/{questions.length} پاسخ‌داده‌شده
</p>
<Button
size="lg"
variant="gradient"
disabled={!allAnswered || submitting}
onClick={submit}
className="min-w-44"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
ثبت و بررسی
</Button>
</div>
</div>
);
}
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 <PassScreen result={result} onNext={() => router.push("/cap")} onLeagues={() => router.push("/leaderboard")} />;
const showLock = result.locked && !infiniteHearts;
return (
<div className="mx-auto max-w-md space-y-5 pt-4">
<Card className="overflow-hidden p-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-destructive/10">
<AlertTriangle className="h-9 w-9 text-destructive" />
</div>
<h2 className="text-2xl font-bold">هنوز کافی نیست</h2>
<p className="mt-1 text-sm text-muted-foreground">
{formatFaNumber(result.correctCount)}/{formatFaNumber(result.total)} پاسخ درست. درس را مرور کنید و دوباره تلاش کنید
{!infiniteHearts && " — هر پاسخ اشتباه یک قلب کم می‌کند"}.
</p>
<div className="my-5">
<Progress value={pct} className="h-3" />
<p className="mt-1 text-xs text-muted-foreground">{pct}٪ درست</p>
</div>
<div className="flex justify-center">
{showLock ? (
<Button variant="destructive" onClick={() => router.push("/lockout")}>
<Lock className="h-4 w-4" /> حساب قفل شد
</Button>
) : (
<Button variant="outline" onClick={onRetake}>
<ChevronLeft className="h-4 w-4 rotate-180" /> تلاش مجدد
</Button>
)}
</div>
</Card>
<p className="text-center text-xs text-muted-foreground">
قلب‌های باقی‌مانده: <HeartsMeterInline hearts={result.heartsLeft} infinite={infiniteHearts} />
</p>
</div>
);
}
function HeartsMeterInline({ hearts, infinite }: { hearts: number; infinite?: boolean }) {
return <HeartsMeter hearts={hearts} size="sm" infinite={infinite} />;
}
function PassScreen({ result, onNext, onLeagues }: { result: QuizResult; onNext: () => void; onLeagues: () => void }) {
return (
<div className="mx-auto max-w-md rounded-lg border border-border bg-card p-10 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-primary">
<PartyPopper className="h-9 w-9 text-primary-foreground" />
</div>
<h2 className="text-2xl font-bold text-primary">آزمون قبول شد!</h2>
<p className="mt-1 text-sm text-muted-foreground">پیشرفت جدی. درس‌های بعدی آزاد شدند.</p>
<div className="mt-5 flex items-center justify-center gap-3">
<Badge variant="gold" className="gap-1 px-3 py-1.5 text-sm">
<Sparkles className="h-4 w-4" /> {formatFaNumber(result.xpEarned)} امتیاز
</Badge>
<Badge variant="accent" className="gap-1 px-3 py-1.5 text-sm">
<Flame className="h-4 w-4" /> {formatStreakLabel(result.streak)}
</Badge>
</div>
<div className="mt-6 flex justify-center gap-2">
<Button variant="outline" onClick={onLeagues}>لیگ‌ها</Button>
<Button variant="gradient" size="lg" onClick={onNext}>
ادامه مسیر <ChevronLeft className="h-4 w-4" />
</Button>
</div>
</div>
);
}
+420
View File
@@ -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 (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
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<CaptchaChallenge | null>(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
setCaptchaAnswer("");
try {
const data = await http.get<CaptchaChallenge>("/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 (
<div className="mx-auto w-full max-w-md space-y-6">
{description && (
<p className="text-center text-sm leading-relaxed text-muted-foreground">{description}</p>
)}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-bold text-foreground">
گام {step} از {STEPS.length}
</span>
<span className="text-muted-foreground">{currentStep?.hint}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
<ol className="grid grid-cols-3 gap-2" aria-label="مراحل ثبت‌نام">
{STEPS.map((s) => {
const active = step === s.id;
const done = step > s.id;
return (
<li
key={s.id}
className={cn(
"rounded-lg px-2 py-2 text-center text-xs font-medium transition-colors",
active && "bg-primary/10 text-primary",
done && "text-primary",
!active && !done && "text-muted-foreground",
)}
aria-current={active ? "step" : undefined}
>
{s.title}
</li>
);
})}
</ol>
</div>
<form onSubmit={(ev) => void submit(ev)} className="space-y-4">
{step === 1 && (
<>
<Field id="reg-name" label="نام و نام خانوادگی">
<Input
id="reg-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
autoFocus
/>
</Field>
<Field id="reg-username" label="شناسه کاربری">
<Input
id="reg-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
dir="ltr"
className="text-left"
autoComplete="username"
/>
</Field>
<Field id="reg-phone" label="شماره تلفن">
<Input
id="reg-phone"
value={phone}
onChange={(e) => setPhone(clampPhoneInput(e.target.value))}
dir="ltr"
className="text-left"
placeholder="0912…"
autoComplete="tel"
inputMode="tel"
maxLength={PHONE_INPUT_MAX_LEN}
/>
</Field>
</>
)}
{step === 2 && (
<>
<Field id="reg-email" label="ایمیل">
<Input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="ltr"
className="text-left"
autoComplete="email"
autoFocus
/>
</Field>
<Field id="reg-secQ" label="سوال امنیتی">
<Input
id="reg-secQ"
value={securityQuestion}
onChange={(e) => setSecurityQuestion(e.target.value)}
placeholder="مثلاً نام مدرسهٔ اول؟"
/>
</Field>
<Field id="reg-secA" label="جواب امنیتی">
<PasswordInput
id="reg-secA"
value={securityAnswer}
onChange={(e) => setSecurityAnswer(e.target.value)}
autoComplete="off"
dir="rtl"
placeholder="جواب سوال امنیتی"
/>
</Field>
</>
)}
{step === 3 && (
<>
<Field id="reg-password" label="رمز عبور">
<PasswordInput
id="reg-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
autoComplete="new-password"
autoFocus
placeholder="حداقل ۸ نویسه"
/>
</Field>
<Field id="reg-password-confirm" label="تکرار رمز عبور">
<PasswordInput
id="reg-password-confirm"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
minLength={8}
autoComplete="new-password"
placeholder="همان رمز را دوباره وارد کنید"
/>
</Field>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="reg-captcha">کد امنیتی</Label>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading}
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-4 hover:underline disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${captchaLoading ? "animate-spin" : ""}`} />
تازه‌سازی
</button>
</div>
<div className="flex items-stretch gap-3">
<div
className="relative flex h-14 min-w-[9.5rem] shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 shadow-inner"
aria-live="polite"
>
{captchaLoading || !challenge?.imageBase64 ? (
<span className="text-sm text-muted-foreground">…</span>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`data:image/png;base64,${challenge.imageBase64}`}
alt="کد امنیتی"
width={160}
height={52}
className="h-full w-full object-contain select-none"
draggable={false}
/>
)}
</div>
<Input
id="reg-captcha"
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.toUpperCase())}
required
dir="ltr"
className="h-14 text-center font-mono tracking-widest uppercase placeholder:text-center"
placeholder="کد تصویر"
maxLength={6}
/>
</div>
</div>
</>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex items-stretch gap-2.5 pt-1">
{step > 1 && (
<Button
type="button"
variant="outline"
size="lg"
className="h-12 shrink-0 px-4"
onClick={goBack}
disabled={loading}
>
<ArrowRight className="h-4 w-4" />
قبلی
</Button>
)}
{step < 3 ? (
<Button type="submit" variant="gradient" className="h-12 min-w-0 flex-1" size="lg">
بعدی
<ArrowLeft className="h-4 w-4" />
</Button>
) : (
<Button
type="submit"
variant="gradient"
className="h-12 min-w-0 flex-1"
size="lg"
disabled={loading || captchaLoading || !challenge}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
{submitLabel}
</Button>
)}
</div>
</form>
<p className="text-center text-sm text-muted-foreground">
حساب دارید؟{" "}
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
ورود
</Link>
</p>
</div>
);
}
+277
View File
@@ -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<string, typeof Terminal> = {
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<number, TreeLesson>();
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<Set<number>>(() => {
const s = new Set<number>();
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 (
<div className="rounded-2xl border-2 border-border bg-background p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-primary/10 text-primary">
<Terminal className="h-5 w-5" />
</span>
<div>
<h2 className="text-lg font-black tracking-tight">نقشهٔ مسیر</h2>
<p className="text-xs text-muted-foreground">
{completed}/{all.length} درس تکمیل‌شده · {all.length - completed} باقی‌مانده
</p>
</div>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted sm:w-56">
<div
className="h-full rounded-full bg-gradient-to-l from-primary to-primary/60"
style={{ width: `${all.length ? Math.max((completed / all.length) * 100, 2) : 0}%` }}
/>
</div>
</div>
<div className="space-y-3">
{chapters.map((ch) => (
<ChapterRow
key={ch.id}
ch={ch}
byId={byId}
open={expanded.has(ch.id)}
onToggle={() => toggle(ch.id)}
onSelect={onSelect ?? ((id: number) => router.push(`/player/${id}`))}
/>
))}
</div>
</div>
);
}
function ChapterRow({
ch,
byId,
open,
onToggle,
onSelect,
}: {
ch: TreeChapter;
byId: Map<number, TreeLesson>;
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 (
<div
className={cn(
"overflow-hidden rounded-xl border-2 bg-card transition-colors duration-300",
isComplete ? "border-success/40" : open ? "border-primary/50" : "border-border hover:border-primary/40"
)}
>
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3.5 text-start"
aria-expanded={open}
>
<span className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-lg", isComplete ? "bg-success/10 text-success" : "bg-primary/10 text-primary")}>
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-extrabold">{ch.title}</h3>
{isComplete && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-success/10 px-2 py-0.5 text-[11px] font-bold text-success">
<CheckCircle2 className="h-3.5 w-3.5" /> کامل شد
</span>
)}
<span className="hidden shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground sm:inline-block">
هفتهٔ {ch.sortOrder}
</span>
</div>
<p className="truncate text-xs text-muted-foreground">{ch.description}</p>
</div>
<div className="shrink-0 text-end">
<p className="text-xs font-bold text-muted-foreground">
<span className="font-black text-foreground">{done}</span>/{lessons.length} درس
</p>
<div className="mt-1 h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", isComplete ? "bg-success" : "bg-primary")}
style={{ width: `${lessons.length ? Math.max((done / lessons.length) * 100, 3) : 0}%` }}
/>
</div>
</div>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-300", open && "rotate-180")} />
</button>
<div className={cn("grid transition-[grid-template-rows] duration-300 ease-out", open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
<div className="overflow-hidden">
<div className="grid gap-2.5 border-t-2 border-border/70 p-4 sm:grid-cols-2">
{lessons.map((lesson) => (
<LessonRow key={lesson.id} lesson={lesson} byId={byId} onSelect={onSelect} />
))}
</div>
</div>
</div>
</div>
);
}
function LessonRow({
lesson,
byId,
onSelect,
}: {
lesson: TreeLesson;
byId: Map<number, TreeLesson>;
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 (
<div
role={locked ? undefined : "button"}
tabIndex={locked ? undefined : 0}
className={cn(
"group flex items-center gap-3 rounded-xl border-2 p-3 transition-colors duration-300",
locked ? "cursor-default" : "cursor-pointer hover:border-primary/50",
s.row
)}
onClick={() => {
if (!locked) onSelect(lesson.id);
}}
onKeyDown={(e) => {
if (locked) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(lesson.id);
}
}}
>
<span className="relative grid h-9 w-9 shrink-0 place-items-center rounded-lg">
{(ready || retry) && <span className="absolute inline-flex h-full w-full animate-ping rounded-lg bg-primary/25" />}
<span className={cn("relative grid h-9 w-9 place-items-center rounded-lg", s.chip)}>
{locked ? <Lock className="h-4 w-4" /> : passed ? <CheckCircle2 className="h-5 w-5" /> : retry ? <RotateCcw className="h-4 w-4" /> : ready ? <BookOpenCheck className="h-4 w-4" /> : <PlayCircle className="h-5 w-5" />}
</span>
</span>
<div className={cn("min-w-0 flex-1", s.muted && "opacity-80")}>
<div className="flex items-center justify-between gap-2">
<p className={cn("truncate text-sm font-bold", locked && "text-muted-foreground")}>{lesson.title}</p>
{!locked && !passed && (
<span className="shrink-0 text-[11px] font-black tabular-nums text-muted-foreground/70">+{formatFaNumber(lesson.xpReward)} امتیاز</span>
)}
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className={cn("rounded-full px-2 py-0.5 text-[11px] font-bold", s.pill)}>{s.pillText}</span>
{inProgress && (
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
)}
{locked && prereq && (
<span className="text-[11px] leading-snug text-muted-foreground">
بعد از تکمیل «{prereq.title}»
</span>
)}
</div>
</div>
{locked ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
) : (
<ChevronLeft className="h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform group-hover:-translate-x-0.5" />
)}
</div>
);
}
+594
View File
@@ -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<HTMLVideoElement>(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<void>>(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<string | null>(null);
const [uiIdle, setUiIdle] = useState(false);
const [mediaDuration, setMediaDuration] = useState(duration);
const hideTimer = useRef<number | null>(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 (
<div className="space-y-4">
<div
className="group relative overflow-hidden rounded-xl border border-border bg-black shadow-soft"
onMouseMove={poke}
onPointerDown={poke}
>
<video
ref={videoRef}
src={lockedSrcRef.current}
className="aspect-video w-full"
controls={false}
controlsList="nodownload noplaybackrate noremoteplayback nofullscreen"
disablePictureInPicture
playsInline
preload="metadata"
draggable={false}
onDragStart={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
onPlay={() => {
const el = videoRef.current;
if (el) {
el.playbackRate = 1;
el.defaultPlaybackRate = 1;
}
setLoadError(null);
onPlayChange();
poke();
}}
onPause={() => {
onPlayChange();
poke();
}}
onWaiting={() => setBuffering(true)}
onPlaying={() => {
setLoading(false);
setBuffering(false);
}}
onCanPlay={() => {
setLoading(false);
setBuffering(false);
}}
onLoadedMetadata={() => {
const el = videoRef.current;
if (el && Number.isFinite(el.duration) && el.duration > 0) {
setMediaDuration(el.duration);
}
}}
onError={() => {
setLoading(false);
setBuffering(false);
setLoadError("پخش ویدیو قطع شد. در حال تلاش برای اتصال مجدد…");
void refreshSignedUrl();
}}
muted={muted}
/>
{(loading || buffering) && !loadError && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="flex flex-col items-center gap-3">
<Skeleton className="h-24 w-24 rounded-full" />
<Skeleton className="h-3 w-24 rounded-full" />
</div>
</div>
)}
{loadError && (
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center gap-3 bg-black/80 px-6 text-center">
<p className="text-sm font-bold text-white">{loadError}</p>
<Button type="button" variant="secondary" onClick={() => void refreshSignedUrl()}>
تلاش دوباره
</Button>
</div>
)}
<div className="absolute inset-0 flex items-center justify-center" onClick={poke}>
{!playing && !buffering && !loading && !loadError && (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
onClick={toggle}
className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90"
aria-label="پخش"
>
<Play className="ms-1 h-8 w-8 fill-white" />
</motion.button>
)}
</div>
<div
className={`absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-4 transition-opacity duration-300 ${
uiIdle ? "pointer-events-none opacity-0" : "opacity-100"
}`}
>
<div className="mb-2 flex items-center gap-1 text-[11px] font-medium text-white/90">
<span className="rounded bg-white/10 px-2 py-0.5">تماشای تاییدشده</span>
<Lock className="h-3 w-3 opacity-60" />
<span className="opacity-80">پرش سریع غیرفعال است</span>
<span className="ms-auto tabular-nums">
{fmt(position)} / {fmt(mediaDuration)}
</span>
</div>
<div className="relative">
<Progress value={pct} className="h-2 bg-white/15" gradient />
<div
className="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-white shadow transition-[inset-inline-start] duration-300 ease-out"
style={{ insetInlineStart: `calc(${pct}% - 8px)` }}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggle}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="پخش / توقف"
>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</button>
<button
type="button"
onClick={skipBack}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="بازگشت ۱۰ ثانیه"
title="۱۰ ثانیه عقب"
>
<SkipBack className="h-5 w-5 scale-x-[-1]" />
</button>
<button
type="button"
onClick={restartFromStart}
className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-3 py-2 text-xs font-bold text-white transition hover:bg-white/20"
aria-label="شروع از اول"
title="شروع از اول"
>
<RotateCcw className="h-4 w-4" />
شروع از اول
</button>
<button
type="button"
onClick={() => setMuted((m) => !m)}
className="rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
aria-label="قطع صدا"
>
{muted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
</button>
<span className="ms-auto text-xs text-white/70">
{Math.round(watchedPct)}٪ تماشای تاییدشده
</span>
</div>
</div>
{quizUnlockedNow && (
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="absolute end-4 top-4 z-30"
>
<Button asChild variant="gradient" size="lg">
<Link href={`/quiz/${lessonId}`}>شروع آزمون</Link>
</Button>
</motion.div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-card p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">پیشرفت تماشای تاییدشده</p>
<p className="text-xs text-muted-foreground">
ضربان‌های ارسالی هر {INTERVAL_MS / 1000} ثانیه، زمان واقعی تماشا را تایید می‌کنند. رسیدن به آستانه{" "}
{completionThresholdPct}٪ آزمون را باز می‌کند.
</p>
</div>
<div className="w-40 shrink-0">
<Progress value={watchedPct} gradient smooth />
<p className="mt-1 text-end text-xs text-muted-foreground">{Math.round(watchedPct)}٪</p>
</div>
{quizUnlocked ? (
<Button asChild variant="gradient">
<Link href={`/quiz/${lessonId}`}>شروع آزمون</Link>
</Button>
) : (
<Button disabled variant="outline">
<Lock className="h-4 w-4" /> آزمون قفل است
</Button>
)}
</div>
</div>
);
}