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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user