feat: release v0.2.0 profiles, chat, freeze, and bots

Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
ahp
2026-10-10 16:42:35 +03:30
parent 8586bbafcc
commit 22d92c133d
85 changed files with 10510 additions and 1341 deletions
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-store";
export default function BotDocsPage() {
const user = useAuth((s) => s.user);
const router = useRouter();
useEffect(() => {
if (user && user.role !== "admin") {
router.replace("/admin");
}
}, [user, router]);
if (!user || user.role !== "admin") {
return null;
}
return (
<div className="mx-auto max-w-3xl space-y-4">
<header className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
<Link href="/admin?tab=bots" className="underline-offset-4 hover:underline">
ربات
</Link>
</p>
<h1 className="text-2xl font-black tracking-tight">راهنمای ربات</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
با یک کلید API می‌توانید از بیرون پرگار به هنرجو پیام بفرستید و جواب‌های او را بخوانید.
</p>
</header>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">ساخت کلید</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
در مدیریت، بخش ربات، یک نام بگذارید و «ساخت ربات» را بزنید. پاسخ ساخت شامل فیلد <Code>token</Code> است.
توکن فقط یک بار نشان داده می‌شود. بعد از بستن یا بارگذاری دوبارهٔ صفحه دیگر برنمی‌گردد؛ سرور فقط هش آن را نگه می‌دارد.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
هر ردیف نام، زمان ساخت، وضعیت و تعداد درخواست‌های تأییدشده را نشان می‌دهد. با «توقف» کلید متوقف می‌شود و با «فعال‌کردن» دوباره کار می‌کند.
</p>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">احراز هویت</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
هر دو مسیر زیر هدر <Code>X-Bot-Token</Code> می‌خواهند. توکن خالی، ناشناس یا متوقف با وضعیت ۴۰۱ و پیام «توکن ربات نامعتبر است» رد می‌شود.
درخواست ناموفق در شمارنده حساب نمی‌شود. هر فراخوانی که توکن فعالش پذیرفته شود، یک واحد به شمار درخواست همان کلید اضافه می‌کند.
</p>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">ارسال پیام</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
<Code>POST /api/bot/send</Code> پیامی از طرف ربات در گفتگوی هنرجو می‌سازد. بدنه JSON:
</p>
<ul className="list-disc space-y-1 pe-5 text-sm leading-relaxed text-muted-foreground">
<li>
<Code>userId</Code> شناسهٔ عددی هنرجو. لازم است.
</li>
<li>
<Code>text</Code> متن پیام. لازم است و نباید خالی باشد.
</li>
<li>
<Code>buttons</Code> آرایهٔ دکمه‌ها. هر عضو شیئی با فیلد <Code>text</Code> است. در گفتگو، زدن دکمه همان متن را به عنوان پیام هنرجو برای ربات می‌فرستد.
</li>
</ul>
<p className="text-sm leading-relaxed text-muted-foreground">
پاسخ موفق: <Code>{`{ "messageId": 123 }`}</Code>. اگر <Code>userId</Code> یا <Code>text</Code> نباشد، وضعیت ۴۰۰ و پیام «userId و text لازم است».
</p>
<Example
title="نمونه"
code={`curl -X POST /api/bot/send \\
-H 'Content-Type: application/json' \\
-H 'X-Bot-Token: YOUR_TOKEN' \\
-d '{"userId":12,"text":"سلام","buttons":[{"text":"باشه"}]}'`}
/>
</section>
<section className="space-y-3 rounded-2xl border border-border bg-card p-5">
<h2 className="text-base font-black">خواندن پاسخ‌ها</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
<Code>GET /api/bot/updates</Code> صف پاسخ‌هایی را برمی‌گرداند که هنرجو برای این ربات فرستاده است؛ چه متن آزاد، چه متن یک دکمه.
پارامتر <Code>offset</Code> آخرین شناسه‌ای است که قبلاً دیده‌اید. فقط ردیف‌های با <Code>id</Code> بزرگ‌تر برمی‌گردند، حداکثر ۵۰ تا. اگر نفرستید، از صفر شروع می‌شود.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
پاسخ: <Code>{`{ "updates": [ { "id": 1, "payload": { "from": 12, "name": "نام", "text": "جواب" }, "createdAt": "..." } ] }`}</Code>.
<Code>from</Code> شناسهٔ فرستنده است، <Code>name</Code> نام او و <Code>text</Code> متن پیام. اگر چیزی نباشد، <Code>updates</Code> آرایهٔ خالی است.
</p>
</section>
</div>
);
}
function Code({ children }: { children: string }) {
return (
<code dir="ltr" className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.8em]">
{children}
</code>
);
}
function Example({ title, code }: { title: string; code: string }) {
return (
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">{title}</p>
<pre dir="ltr" className="overflow-x-auto rounded-2xl border border-border bg-muted/40 p-3 text-left font-mono text-xs leading-relaxed">
{code}
</pre>
</div>
);
}
+11 -7
View File
@@ -1,10 +1,10 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Loader2, Package } from "lucide-react";
import { CertificateCard } from "@/components/certificate-card";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { http, toUserError } from "@/lib/api";
@@ -96,12 +96,16 @@ export default function CertificatePage() {
if (error || !data?.certificate) {
return (
<div className="mx-auto max-w-lg px-4 py-16 text-center">
<p className="text-lg font-bold">گواهینامه پیدا نشد</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/">بازگشت</Link>
</Button>
</div>
<NotFoundState
title="گواهینامه پیدا نشد"
description={notFoundSentence(
error ? toUserError(error, "") : "",
"این گواهینامه در سامانه نیست.",
"گواهینامه پیدا نشد",
)}
href="/"
actionLabel="بازگشت به خانه"
/>
);
}
+243 -2
View File
@@ -2,7 +2,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Suspense, useEffect, useRef } from "react";
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Award,
ArrowLeft,
@@ -28,6 +28,7 @@ import { useAuth } from "@/lib/auth-store";
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
import { clampHearts, hasInfiniteHearts, INFINITE_HEARTS_LABEL, MAX_HEARTS } from "@/lib/hearts";
import { toast } from "@/components/providers";
import { SafeMarkdown } from "@/components/safe-markdown";
export default function CapPage() {
return (
@@ -150,6 +151,8 @@ function CapPageInner() {
</div>
</div>
<PinnedAnnouncement />
{lowHearts && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-5 py-4">
<div className="flex items-start gap-3">
@@ -157,7 +160,7 @@ function CapPageInner() {
<div>
<p className="text-sm font-extrabold text-destructive">فقط {formatFaNumber(hearts)} جان مانده</p>
<p className="text-xs text-muted-foreground">
یک اشتباه دیگر حساب را قفل می‌کند. قبل از آزمون مطمئن شوید؛ اگر گیر کردید با منتور حرف بزنید.
جان‌ها خودشان برمی‌گردند. تا برگشتن یک جان، آزمون تازه صبر می‌کند.
</p>
</div>
</div>
@@ -450,6 +453,244 @@ function CapSkeleton() {
);
}
type CapAnnouncement = { title: string; body: string; route: string };
function PinnedAnnouncement() {
const router = useRouter();
const scroller = useRef<HTMLDivElement>(null);
const gesture = useRef<{
x: number;
y: number;
scroll: number;
index: number;
pointerId: number;
axis: "x" | "y" | null;
dx: number;
} | null>(null);
const moved = useRef(0);
const [index, setIndex] = useState(0);
const q = useQuery({
queryKey: ["announcement-pinned"],
queryFn: () =>
http.get<{ announcement: CapAnnouncement | null; announcements?: CapAnnouncement[] }>("/api/announcements/pinned"),
});
const items = q.data?.announcements?.length
? q.data.announcements
: q.data?.announcement
? [q.data.announcement]
: [];
const count = items.length;
const indexRef = useRef(0);
const hoverRef = useRef(false);
const downRef = useRef(false);
const programmatic = useRef(0);
const scrollSeq = useRef(0);
const wantScroll = useRef(false);
const scrollBehavior = useRef<ScrollBehavior>("smooth");
const [epoch, setEpoch] = useState(0);
const stride = (el: HTMLDivElement) => {
const card = el.firstElementChild as HTMLElement | null;
const gap = 12;
return (card?.offsetWidth || el.clientWidth || 1) + gap;
};
const goTo = (next: number, behavior: ScrollBehavior = "smooth") => {
if (count < 2) return;
const i = ((next % count) + count) % count;
const from = indexRef.current;
const neighbor = Math.abs(i - from) === 1;
scrollBehavior.current = neighbor && behavior === "smooth" ? "smooth" : "auto";
wantScroll.current = true;
indexRef.current = i;
setIndex(i);
setEpoch((n) => n + 1);
};
const stepBy = (delta: number) => {
const from = indexRef.current;
const dest = from + delta < 0 ? count - 1 : from + delta >= count ? 0 : from + delta;
goTo(dest, Math.abs(dest - from) === 1 ? "smooth" : "auto");
};
const stepRef = useRef(stepBy);
stepRef.current = stepBy;
useLayoutEffect(() => {
const el = scroller.current;
if (!el || count < 2 || !wantScroll.current) return;
wantScroll.current = false;
const token = scrollSeq.current + 1;
scrollSeq.current = token;
programmatic.current = token;
const left = indexRef.current * stride(el);
let settledOnce = false;
const finish = (force: boolean) => {
if (settledOnce || scrollSeq.current !== token) return;
if (gesture.current?.axis === "x") return;
const target = indexRef.current * stride(el);
const settled = Math.abs(el.scrollLeft - target) <= 2;
if (!settled && !force) return;
if (!settled) {
el.style.scrollBehavior = "auto";
el.scrollLeft = target;
}
settledOnce = true;
if (programmatic.current === token) programmatic.current = 0;
el.removeEventListener("scrollend", onEnd);
};
const onEnd = () => {
if (scrollSeq.current !== token || !programmatic.current) return;
finish(false);
};
el.addEventListener("scrollend", onEnd);
el.style.scrollBehavior = scrollBehavior.current === "smooth" ? "smooth" : "auto";
el.scrollTo({ left, behavior: scrollBehavior.current });
const soon = window.setTimeout(() => finish(false), scrollBehavior.current === "auto" ? 40 : 700);
const later = window.setTimeout(() => finish(true), 1200);
return () => {
window.clearTimeout(soon);
window.clearTimeout(later);
el.removeEventListener("scrollend", onEnd);
};
}, [index, epoch, count]);
useEffect(() => {
if (count < 2) return;
const id = window.setInterval(() => {
if (hoverRef.current || downRef.current) return;
stepRef.current(1);
}, 6000);
return () => window.clearInterval(id);
}, [count, epoch]);
if (count === 0) return null;
const hold = (hover: boolean | null, down: boolean | null) => {
if (hover != null) hoverRef.current = hover;
if (down != null) downRef.current = down;
};
const go = (next: number) => goTo(next);
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
const el = scroller.current;
if (!el) return;
gesture.current = {
x: e.clientX,
y: e.clientY,
scroll: el.scrollLeft,
index: indexRef.current,
pointerId: e.pointerId,
axis: null,
dx: 0,
};
moved.current = 0;
};
const onTrackPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
const dx = e.clientX - g.x;
const dy = e.clientY - g.y;
g.dx = dx;
if (g.axis == null) {
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
}
if (g.axis !== "x" || count < 2) return;
const el = scroller.current;
if (!el) return;
if (!el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
const width = stride(el);
const max = width * (count - 1);
const pulled = g.scroll - dx;
const limited = Math.min(g.scroll + width, Math.max(g.scroll - width, pulled));
el.style.scrollBehavior = "auto";
el.scrollLeft = Math.min(max, Math.max(0, limited));
};
const onTrackPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
moved.current = Math.max(Math.abs(g.dx), Math.abs(e.clientY - g.y));
const el = scroller.current;
if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
if (g.axis !== "x" || count < 2) {
gesture.current = null;
if (el) el.style.scrollBehavior = "";
return;
}
const threshold = 40;
let next = g.index;
if (g.dx <= -threshold) next = g.index + 1;
else if (g.dx >= threshold) next = g.index - 1;
go(next);
gesture.current = null;
};
return (
<div
className="relative"
onPointerEnter={() => hold(true, null)}
onPointerLeave={() => {
if (gesture.current?.axis === "x") {
hold(false, null);
return;
}
hold(false, false);
setEpoch((n) => n + 1);
}}
onPointerDown={() => hold(null, true)}
onPointerUp={() => hold(null, false)}
onPointerCancel={() => hold(null, false)}
>
<div
ref={scroller}
dir="ltr"
style={{ direction: "ltr" }}
className="flex gap-3 touch-pan-y select-none overflow-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onPointerDown={onTrackPointerDown}
onPointerMove={onTrackPointerMove}
onPointerUp={onTrackPointerUp}
onPointerCancel={onTrackPointerUp}
>
{items.map((a, i) => (
<button
key={`${a.title}-${i}`}
type="button"
dir="rtl"
draggable={false}
onClick={() => {
if (Math.abs(moved.current) > 12) return;
if (a.route) router.push(a.route);
}}
className="block w-[calc(100%-2rem)] min-w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] shrink-0 touch-pan-y rounded-2xl border-2 border-primary/30 bg-primary/5 px-5 py-4 text-start"
>
<p className="text-xs font-bold text-primary">اطلاعیه</p>
<p className="mt-1 text-base font-black">{a.title}</p>
<SafeMarkdown text={a.body} lines={3} className="mt-1 text-sm text-muted-foreground" />
</button>
))}
</div>
{items.length > 1 && (
<div className="mt-2 flex items-center justify-center gap-1.5">
{items.map((a, i) => (
<button
key={`${a.title}-dot-${i}`}
type="button"
aria-label={`اطلاعیه ${i + 1}`}
onClick={() => go(i)}
className={cn("h-1.5 rounded-full transition-all", i === index ? "w-4 bg-primary" : "w-1.5 bg-primary/30")}
/>
))}
</div>
)}
</div>
);
}
function ZapIcon(props: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
+25
View File
@@ -254,6 +254,31 @@
border-radius: 999px;
border-width: 0;
}
textarea.chat-composer {
unicode-bidi: plaintext;
text-align: start;
white-space: pre-wrap;
overflow-wrap: anywhere;
field-sizing: content;
max-height: 16rem;
overflow-y: auto;
}
textarea.chat-composer:placeholder-shown {
direction: rtl;
text-align: right;
}
textarea.chat-composer::placeholder {
unicode-bidi: plaintext;
text-align: start;
}
.chat-bubble-text {
unicode-bidi: plaintext;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
max-width: 100%;
text-align: start;
}
.section-kicker {
display: flex;
align-items: center;
+2 -3
View File
@@ -89,8 +89,7 @@ export default function LockoutPage() {
<p className="text-xs font-bold tracking-wider text-destructive">حساب قفل شد</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight">جان‌هایتان تمام شد</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
هر پاسخ اشتباه در آزمون یک جان کم می‌کند. با تمام شدن سه جان، یادگیری و آزمون قفل می‌شود تا
تیم مسیر را بررسی کند — خودتان نمی‌توانید قفل را باز کنید.
جان‌ها معمولاً خودشان برمی‌گردند. این صفحه وقتی دیده می‌شود که مدیریت حساب را قفل کرده باشد.
</p>
<div className="mt-6 flex items-center gap-2">
@@ -144,7 +143,7 @@ export default function LockoutPage() {
className={cn("w-full text-muted-foreground")}
onClick={() => router.push("/unwrap?tab=community")}
>
جامعه و حمایت
شبکه‌های پرگار
</Button>
</div>
</div>
+13 -5
View File
@@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, ShieldCheck, Timer, Zap } from "lucide-react";
import { BrandMark } from "@/components/brand-mark";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
import { ApiError, http, toUserError } from "@/lib/api";
import type { User } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -34,6 +34,7 @@ export default function LoginPage() {
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [captchaError, setCaptchaError] = useState<string | null>(null);
const { data: registerStatus } = useQuery({
queryKey: ["auth", "register-status"],
@@ -43,7 +44,7 @@ export default function LoginPage() {
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!challenge) {
setError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaError("ابتدا کد امنیتی را بارگذاری کنید");
setCaptchaKey((k) => k + 1);
return;
}
@@ -61,7 +62,10 @@ export default function LoginPage() {
router.push("/cap");
} catch (err) {
setError(toUserError(err, "ورود ناموفق بود"));
setCaptchaKey((k) => k + 1);
// 429 is returned before the captcha is consumed, so keep this challenge.
if (!(err instanceof ApiError) || err.status !== 429) {
setCaptchaKey((k) => k + 1);
}
} finally {
setLoading(false);
}
@@ -137,7 +141,7 @@ export default function LoginPage() {
value={captchaAnswer}
onChange={setCaptchaAnswer}
onChallenge={setChallenge}
onError={setError}
onError={setCaptchaError}
/>
<label className="flex items-center justify-between gap-3 text-sm">
<span>مرا به یاد داشته باش</span>
@@ -147,7 +151,11 @@ export default function LoginPage() {
aria-label="مرا به یاد داشته باش"
/>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
{(error || captchaError) && (
<p className="text-sm text-destructive" role="alert">
{error || captchaError}
</p>
)}
<Button
type="submit"
variant="gradient"
+7 -53
View File
@@ -1,58 +1,12 @@
"use client";
import Link from "next/link";
import { BrandMark } from "@/components/brand-mark";
import { Button } from "@/components/ui/button";
import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { Home, LogIn, Trees } from "lucide-react";
import { NotFoundState } from "@/components/not-found-state";
export default function NotFound() {
const hydrated = useAuthHydrated();
const user = useAuth((s) => s.user);
const loggedIn = hydrated && !!user;
return (
<div className="mx-auto flex max-w-lg flex-col items-center px-4 py-16 text-center">
<div className="relative w-full overflow-hidden rounded-2xl border-2 border-border bg-card px-8 py-14 shadow-offset">
<div className="dot-grid dot-grid-primary pointer-events-none absolute -end-10 -top-10 h-40 w-40 opacity-60" aria-hidden />
<p className="text-7xl font-black tabular-nums text-primary/25">۴۰۴</p>
<div className="mt-4 flex justify-center">
<BrandMark locale="fa" size="lg" />
</div>
<h1 className="mt-6 text-2xl font-black tracking-tight">این مسیر پیدا نشد</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{loggedIn
? "این آدرس در سامانه نیست. از درخت مهارت یا مرکز ادامه دهید."
: "صفحه‌ای که دنبالش بودید پیدا نشد. می‌توانید به خانه برگردید یا وارد حساب شوید."}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
{loggedIn ? (
<>
<Button asChild className="gap-2">
<Link href="/cap">
<Trees className="h-4 w-4" /> درخت مهارت
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/unwrap?tab=community">مرکز · حمایت</Link>
</Button>
</>
) : (
<>
<Button asChild className="gap-2">
<Link href="/">
<Home className="h-4 w-4" /> خانه
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/login">
<LogIn className="h-4 w-4" /> ورود
</Link>
</Button>
</>
)}
</div>
</div>
</div>
<NotFoundState
title="صفحه پیدا نشد"
description="این آدرس در سامانه نیست. از خانه می‌توانید دوباره شروع کنید."
href="/"
actionLabel="بازگشت به خانه"
/>
);
}
+24 -1
View File
@@ -6,6 +6,7 @@ import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Lock, TreePine } from "lucide-react";
import { VideoPlayer } from "@/components/video-player";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { ApiError, http, toUserError } from "@/lib/api";
import type { Lesson, LessonProgress } from "@/lib/types";
import { Button } from "@/components/ui/button";
@@ -24,6 +25,7 @@ function isLessonLockedError(error: unknown): boolean {
export default function PlayerPage() {
const { lessonId } = useParams<{ lessonId: string }>();
const id = Number(lessonId);
const idOk = Number.isFinite(id) && id > 0;
const router = useRouter();
const user = useAuth((s) => s.user);
@@ -37,7 +39,7 @@ export default function PlayerPage() {
progress: LessonProgress | null;
quizStatus: string;
}>(`/api/lessons/${id}`),
enabled: !!user && (!user.isLocked || hasInfiniteHearts(user.role)),
enabled: idOk && !!user && (!user.isLocked || hasInfiniteHearts(user.role)),
staleTime: 5 * 60_000,
retry: (count, err) => {
if (isLessonLockedError(err)) return false;
@@ -53,6 +55,17 @@ export default function PlayerPage() {
return null;
}
if (!idOk) {
return (
<NotFoundState
title="درس پیدا نشد"
description="این درس در درخت مهارت نیست."
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
if (isLoading) {
return (
<div className="mx-auto max-w-4xl space-y-6">
@@ -79,6 +92,16 @@ export default function PlayerPage() {
if (isLessonLockedError(error)) {
return <LockedLessonState onBack={() => router.push("/cap")} />;
}
if (error instanceof ApiError && error.status === 404) {
return (
<NotFoundState
title="درس پیدا نشد"
description={notFoundSentence(toUserError(error, ""), "این درس در درخت مهارت نیست.", "درس پیدا نشد")}
href="/cap"
actionLabel="بازگشت به درخت مهارت"
/>
);
}
return (
<div className="mx-auto max-w-lg space-y-4 py-16 text-center">
<p className="font-medium text-destructive">{toUserError(error, "بارگذاری درس ممکن نشد")}</p>
+55 -43
View File
@@ -2,17 +2,15 @@
import type { ReactNode } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Flame, Heart, Sparkles, User as UserIcon } from "lucide-react";
import { Flame, Heart, Sparkles } from "lucide-react";
import { ProfileHeader } from "@/components/profile-header";
import { NotFoundState, notFoundSentence } from "@/components/not-found-state";
import { http, toUserError } from "@/lib/api";
import { useAuth } from "@/lib/auth-store";
import type { PublicProfile } from "@/lib/types";
import { Panel } from "@/components/panel";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
const AdminProfileManage = dynamic(
@@ -20,12 +18,6 @@ const AdminProfileManage = dynamic(
{ ssr: false },
);
function roleLabel(role: string) {
if (role === "mentor") return "منتور";
if (role === "admin") return "ادمین";
return "هنرجو";
}
export default function PublicProfilePage() {
const params = useParams<{ id: string }>();
const id = Number(params.id);
@@ -41,9 +33,12 @@ export default function PublicProfilePage() {
if (!enabled) {
return (
<div className="mx-auto max-w-2xl py-16 text-center text-sm text-muted-foreground">
کاربر پیدا نشد.
</div>
<NotFoundState
title="کاربر پیدا نشد"
description="این شناسه در سامانه نیست."
href="/unwrap"
actionLabel="بازگشت به مرکز"
/>
);
}
@@ -62,14 +57,16 @@ export default function PublicProfilePage() {
if (profileQ.isError || !profileQ.data?.user) {
return (
<div className="mx-auto max-w-2xl space-y-4 py-16 text-center">
<p className="text-sm font-bold text-destructive">
{toUserError(profileQ.error, "کاربر پیدا نشد")}
</p>
<Button asChild variant="outline">
<Link href="/unwrap">بازگشت</Link>
</Button>
</div>
<NotFoundState
title="کاربر پیدا نشد"
description={notFoundSentence(
toUserError(profileQ.error, ""),
"این شناسه در سامانه نیست.",
"کاربر پیدا نشد",
)}
href="/unwrap"
actionLabel="بازگشت به مرکز"
/>
);
}
@@ -77,40 +74,55 @@ export default function PublicProfilePage() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<Panel
title={person.name}
description={roleLabel(person.role)}
icon={<UserIcon className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<UserAvatar name={person.name} className="h-20 w-20" {...avatarPropsOf(person)} />
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
<h1 className="text-2xl font-black tracking-tight">{person.name}</h1>
<p className="font-latin text-sm font-bold" dir="ltr">
@{person.username}
</p>
{person.isLocked && (
<p className="text-xs font-bold text-destructive">حساب قفل است</p>
)}
</div>
</div>
</Panel>
<ProfileHeader
person={{
name: person.name,
role: person.role,
banner: person.banner,
createdAt: person.private ? undefined : person.createdAt,
avatarVariant: person.avatarVariant,
avatarPalette: person.avatarPalette,
avatarPhoto: person.avatarPhoto,
isFrozen: person.isFrozen,
isClosed: person.isClosed,
}}
secondary={person.private ? "این پروفایل خصوصی است" : undefined}
/>
{person.isLocked && <p className="text-xs font-bold text-destructive">حساب قفل است</p>}
{person.private ? (
<p className="text-sm text-muted-foreground">جز نام و آواتار چیزی نمایش داده نمی‌شود.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
<Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />
<Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلب‌ها" value={formatFaNumber(person.hearts)} />
{person.xp != null && <Stat icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز" value={formatFaNumber(person.xp)} />}
{person.hearts != null && <Stat icon={<Heart className="h-4 w-4 text-destructive" />} label="قلب‌ها" value={formatFaNumber(person.hearts)} />}
{person.streakCurrent != null && (
<Stat
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(person.streakCurrent)}
/>
)}
{person.streakLongest != null && (
<Stat
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(person.streakLongest)}
/>
)}
</div>
)}
{(person.socialLinks ?? []).length > 0 && (
<ul className="space-y-1 text-sm">
{person.socialLinks!.map((l) => (
<li key={l.url}>
<a href={l.url} className="text-primary underline" target="_blank" rel="noreferrer">
{l.name}
</a>
</li>
))}
</ul>
)}
{isAdmin && <AdminProfileManage userId={person.id} />}
</div>
+176 -58
View File
@@ -2,16 +2,16 @@
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Flame, Heart, Sparkles, Trophy, User as UserIcon } from "lucide-react";
import { Flame, Heart, Sparkles, Trophy } from "lucide-react";
import { useState } from "react";
import { useAuth } from "@/lib/auth-store";
import { http, toUserError } from "@/lib/api";
import type { LeaderboardData } from "@/lib/types";
import { Panel } from "@/components/panel";
import type { LeaderboardData, MeetingEvent } from "@/lib/types";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
import { ProfileCertificateSlot } from "@/components/profile-certificate-slot";
import { OwnerProfileHeader } from "@/components/profile-header";
import { ChallengeCards } from "@/components/challenge-cards";
import { AvatarStudio } from "@/components/avatar-studio";
import {
@@ -20,8 +20,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatFaNumber, formatStreakLabel } from "@/lib/utils";
import { useState } from "react";
import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils";
export default function ProfilePage() {
const user = useAuth((s) => s.user);
@@ -45,56 +44,7 @@ export default function ProfilePage() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<Panel
title="پروفایل من"
description="هویت، آمار یادگیری، چالش‌ها و گواهینامه در یک نگاه."
icon={<UserIcon className="h-5 w-5" />}
tint="bg-primary/10 text-primary"
actions={
<Button variant="outline" size="sm" onClick={() => setStudioOpen(true)}>
ویرایش آواتار
</Button>
}
>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">
<UserAvatar name={user.name} className="h-20 w-20" {...avatarPropsOf(user)} />
<div className="min-w-0 flex-1 space-y-2 text-center sm:text-start">
<h1 className="text-2xl font-black tracking-tight">{user.name}</h1>
<dl className="mt-1 space-y-2 text-sm">
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">نام کاربری</dt>
<dd className="min-w-0 font-latin font-bold tracking-tight" dir="ltr">
@{user.username}
</dd>
</div>
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">ایمیل</dt>
<dd className="min-w-0 break-all font-latin font-medium" dir="ltr">
{user.email}
</dd>
</div>
{user.phone && (
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">تلفن</dt>
<dd className="min-w-0 font-latin font-medium" dir="ltr">
{user.phone}
</dd>
</div>
)}
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-xs text-muted-foreground">عضویت از</dt>
<dd className="min-w-0 font-medium">
{new Date(user.createdAt).toLocaleDateString("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
})}
</dd>
</div>
</dl>
</div>
</div>
</Panel>
<OwnerProfileHeader user={user} onEditAvatar={() => setStudioOpen(true)} />
<div className="grid gap-3 sm:grid-cols-2">
<StatCard icon={<Sparkles className="h-4 w-4 text-gold" />} label="امتیاز کل" value={formatFaNumber(user.xp)} />
@@ -139,14 +89,16 @@ export default function ProfilePage() {
<ChallengeCards />
</div>
<ProfileActivity />
<div>
<h2 className="mb-2 text-sm font-black text-muted-foreground">گواهینامه</h2>
<ProfileCertificateSlot />
</div>
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[min(85vh,40rem)] max-w-lg flex-col gap-3 overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
</DialogHeader>
<AvatarStudio user={user} onClose={() => setStudioOpen(false)} />
@@ -156,6 +108,172 @@ export default function ProfilePage() {
);
}
function ProfileActivity() {
const activityQ = useQuery({
queryKey: ["me-activity"],
queryFn: () => http.get<{ days: { day: string; count: number }[] }>("/api/me/activity"),
});
const eventsQ = useQuery({
queryKey: ["events", "profile"],
queryFn: () => http.get<{ events: MeetingEvent[] }>("/api/events"),
});
return (
<div>
<h2 className="mb-2 text-sm font-black text-muted-foreground">فعالیت</h2>
<Card>
<CardContent className="space-y-6 p-4">
<div>
<h3 className="text-sm font-black">تاریخچهٔ ماهانه</h3>
<p className="mt-1 text-xs text-muted-foreground">هر بار یک ماه. پررنگ‌تر یعنی فعالیت بیشتر.</p>
{activityQ.isPending ? (
<Skeleton className="mt-3 h-24 rounded-xl" aria-busy="true" aria-label="در حال بارگذاری نمودار فعالیت" />
) : activityQ.isError ? (
<p className="mt-3 text-sm font-bold text-destructive">{toUserError(activityQ.error, "بارگذاری فعالیت ممکن نشد")}</p>
) : (
<ActivityGrid days={activityQ.data?.days ?? []} />
)}
</div>
<div>
<h3 className="text-sm font-black">رویدادها</h3>
{eventsQ.isPending ? (
<p className="mt-2 text-sm text-muted-foreground" aria-busy="true">
در حال بارگذاری…
</p>
) : eventsQ.isError ? (
<p className="mt-2 text-sm font-bold text-destructive">{toUserError(eventsQ.error, "بارگذاری رویدادها ممکن نشد")}</p>
) : (eventsQ.data?.events ?? []).length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">رویدادی نیست.</p>
) : (
<ul className="mt-3 divide-y divide-border rounded-xl border border-border">
{(eventsQ.data?.events ?? []).map((ev) => (
<li key={ev.id} className="flex items-baseline justify-between gap-3 px-3 py-2.5 text-sm">
<span className="font-bold">{ev.title}</span>
<time className="shrink-0 text-xs text-muted-foreground" dateTime={ev.startsAt}>
{formatJalaliStamp(ev.startsAt)}
</time>
</li>
))}
</ul>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function activityCountLabel(count: number) {
if (count <= 0) return "بدون فعالیت";
return `${formatFaNumber(count)} فعالیت`;
}
const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
function ActivityGrid({ days }: { days: { day: string; count: number }[] }) {
const [index, setIndex] = useState(0);
const map = new Map(days.map((d) => [d.day.slice(0, 10), d.count]));
const order: string[] = [];
const months = new Map<string, { label: string; cells: { day: string; count: number }[] }>();
const today = new Date();
today.setHours(12, 0, 0, 0);
for (let i = 119; i >= 0; i--) {
const d = new Date(today);
d.setDate(today.getDate() - i);
const key = d.toISOString().slice(0, 10);
const id = jalaliMonthId(d);
if (!months.has(id)) {
order.push(id);
months.set(id, { label: jalaliMonth(d), cells: [] });
}
months.get(id)!.cells.push({ day: key, count: map.get(key) ?? 0 });
}
const newestFirst = [...order].reverse();
const safeIndex = Math.min(index, Math.max(newestFirst.length - 1, 0));
const id = newestFirst[safeIndex];
const month = id ? months.get(id) : undefined;
if (!month || !id) return null;
const pad = weekdayOffset(month.cells[0]?.day ?? "");
return (
<div className="mt-3" role="group" aria-label="تاریخچهٔ ماهانهٔ فعالیت، به تاریخ جلالی">
<div className="mb-3 flex items-center justify-between gap-3">
<Button type="button" variant="outline" size="sm" disabled={safeIndex <= 0} onClick={() => setIndex(safeIndex - 1)}>
ماه جدیدتر
</Button>
<h4 className="text-sm font-black">{month.label}</h4>
<Button
type="button"
variant="outline"
size="sm"
disabled={safeIndex >= newestFirst.length - 1}
onClick={() => setIndex(safeIndex + 1)}
>
ماه قدیمی‌تر
</Button>
</div>
<div className="grid grid-cols-7 gap-1" dir="ltr">
{WEEKDAYS.map((name) => (
<span key={name} className="text-center text-[10px] font-bold text-muted-foreground" dir="rtl">
{name}
</span>
))}
{Array.from({ length: pad }, (_, i) => (
<span key={`${id}-pad-${i}`} />
))}
{month.cells.map((c) => {
const date = jalaliDay(c.day);
const countLabel = activityCountLabel(c.count);
return (
<button
key={c.day}
type="button"
aria-label={`${date}، ${countLabel}`}
className="group relative z-0 aspect-square min-h-0 w-full min-w-0 cursor-default appearance-none rounded-sm border-0 p-0 outline-none hover:z-30 focus-visible:z-30 focus-visible:ring-2 focus-visible:ring-ring"
style={{ background: c.count === 0 ? "var(--muted)" : `color-mix(in srgb, var(--primary) ${30 + Math.min(c.count, 6) * 12}%, white)` }}
>
<span
role="tooltip"
dir="rtl"
className="pointer-events-none absolute bottom-[calc(100%+0.35rem)] left-1/2 z-30 hidden w-max -translate-x-1/2 rounded-lg border border-border bg-card px-2.5 py-1.5 text-start text-sm leading-snug text-card-foreground shadow-soft group-hover:block group-focus-visible:block"
>
<span className="block font-bold whitespace-nowrap">{date}</span>
<span className="block whitespace-nowrap text-muted-foreground">{countLabel}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
function weekdayOffset(day: string) {
if (!day) return 0;
const d = new Date(day + "T12:00:00");
return (d.getDay() + 1) % 7;
}
function jalaliMonthId(d: Date) {
const parts = new Intl.DateTimeFormat("en-u-ca-persian", { year: "numeric", month: "2-digit" }).formatToParts(d);
const year = parts.find((p) => p.type === "year")?.value ?? "";
const month = parts.find((p) => p.type === "month")?.value ?? "";
return `${year}-${month}`;
}
function jalaliMonth(d: Date) {
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { month: "long", year: "numeric" }).formatToParts(d);
const month = parts.find((p) => p.type === "month")?.value ?? "";
const year = parts.find((p) => p.type === "year")?.value ?? "";
return `${month} ${year}`;
}
function jalaliDay(day: string) {
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(day + "T12:00:00"),
);
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Card>
+874
View File
@@ -0,0 +1,874 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarDays, Flame, Sparkles } from "lucide-react";
import { AccountIdentityFields, AccountUsernameSection } from "@/components/account-identity-fields";
import { SectionColumns, SectionNav, SectionPanel } from "@/components/section-nav";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { ApiError, http, toUserError } from "@/lib/api";
import { validateEmail, validateName, validatePassword, validatePhone, validateUsername } from "@/lib/validation";
import { useAuth } from "@/lib/auth-store";
import { clearMediaCache } from "@/lib/transfer";
import { toast } from "@/components/providers";
import type { Conversation } from "@/lib/types";
import { formatFaNumber, formatJalaliStamp, formatStreakLabel } from "@/lib/utils";
type SocialLink = { name: string; url: string };
type Extras = {
banner: string;
public: boolean;
showActivity: boolean;
showStats: boolean;
showPath: boolean;
showBadges: boolean;
socialLinks: SocialLink[];
theme: string;
};
type Activity = { days: { day: string; count: number }[]; weekDays: number; monthDays: number };
type BadgeItem = { id: string; title: string; unlocked: boolean };
const SECTIONS = [
{ id: "account", label: "حساب", title: "حساب", description: "نام، ایمیل و تلفن را اینجا ذخیره می‌کنید. نام کاربری جدا عوض می‌شود. برای گذرواژه، گذرواژهٔ فعلی لازم است." },
{ id: "privacy", label: "حریم", title: "حریم", description: "مشخص کنید دیگران در پروفایل شما چه چیزی ببینند." },
{ id: "stats", label: "آمار", title: "آمار", description: "امتیاز، زنجیره و نشان‌های همین حساب." },
{ id: "export", label: "خروجی", title: "خروجی", description: "گفتگوها را دانلود کنید یا حافظه موقت را از همین دستگاه حذف کنید." },
] as const;
const EXPORT_PAGE_SIZE = 8;
type SectionId = (typeof SECTIONS)[number]["id"];
export default function SettingsPage() {
const user = useAuth((s) => s.user);
const setUser = useAuth((s) => s.setUser);
const profileQ = useQuery({
queryKey: ["me-profile"],
queryFn: () => http.get<{ profile: Extras }>("/api/me/profile"),
enabled: !!user,
});
const activityQ = useQuery({
queryKey: ["me-activity"],
queryFn: () => http.get<Activity>("/api/me/activity"),
enabled: !!user && (profileQ.data?.profile.showActivity ?? true),
});
const badgesQ = useQuery({
queryKey: ["me-achievements"],
queryFn: () => http.get<{ achievements: BadgeItem[] }>("/api/me/achievements"),
enabled: !!user && (profileQ.data?.profile.showBadges ?? true),
});
const [section, setSection] = useState<SectionId>("account");
const [exportingId, setExportingId] = useState<number | null>(null);
const [exportPage, setExportPage] = useState(1);
const exportChatsQ = useQuery({
queryKey: ["settings-export-chats", user?.id],
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
enabled: !!user && section === "export",
});
const exportChats = exportChatsQ.data?.conversations ?? [];
const exportPageCount = Math.max(1, Math.ceil(exportChats.length / EXPORT_PAGE_SIZE));
const exportPageSafe = Math.min(Math.max(exportPage, 1), exportPageCount);
const exportPageStart = (exportPageSafe - 1) * EXPORT_PAGE_SIZE;
const exportPageChats = exportChats.slice(exportPageStart, exportPageStart + EXPORT_PAGE_SIZE);
const exportSizes = useQueries({
queries: exportChats.map((chat) => ({
queryKey: ["me-export-meta", user?.id, chat.partner.id],
queryFn: () => http.get<{ bytes: number; chat: number; lastAt?: string }>(`/api/me/export?meta=1&chat=${chat.partner.id}`),
enabled: !!user && section === "export",
})),
});
const [form, setForm] = useState<Extras | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [phone, setPhone] = useState(user?.phone ?? "");
const [nextUsername, setNextUsername] = useState("");
const [password, setPassword] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false);
const [savingUsername, setSavingUsername] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [freezeConfirm, setFreezeConfirm] = useState(false);
const [freezePassword, setFreezePassword] = useState("");
const [freezeBusy, setFreezeBusy] = useState(false);
const qc = useQueryClient();
useEffect(() => {
const profile = profileQ.data?.profile;
if (!profile) return;
setForm({
...profile,
socialLinks: normalizeLinks(profile.socialLinks),
});
}, [profileQ.data]);
useEffect(() => {
if (!user || identityDirty) return;
setDisplayName(user.name ?? "");
setEmail(user.email ?? "");
setPhone(user.phone ?? "");
}, [user, identityDirty]);
if (!user) return null;
const usernameCooling = Boolean(
user.usernameCooldownUntil && new Date(user.usernameCooldownUntil).getTime() > Date.now(),
);
const usernameRemaining = user.usernameChangesRemaining ?? 3;
const active = SECTIONS.find((item) => item.id === section) ?? SECTIONS[0];
const activityOn = profileQ.data?.profile.showActivity ?? true;
const badgesOn = profileQ.data?.profile.showBadges ?? true;
const patch = (partial: Partial<Extras>) => {
setForm((prev) => (prev ? { ...prev, ...partial } : prev));
};
const saveProfile = async () => {
if (!form) return;
try {
await http.put("/api/me/profile", {
public: form.public,
showActivity: form.showActivity,
showStats: form.showStats,
showPath: form.showPath,
showBadges: form.showBadges,
socialLinks: form.socialLinks.slice(0, 5),
banner: form.banner,
});
toast.success("تنظیمات ذخیره شد");
} catch (e) {
toast.error(toUserError(e, "ذخیره ممکن نشد"));
}
};
const saveIdentity = async () => {
const nameErr = validateName(displayName);
if (nameErr) {
toast.error(nameErr);
return;
}
const emailErr = validateEmail(email);
if (emailErr) {
toast.error(emailErr);
return;
}
const phoneErr = validatePhone(phone, false);
if (phoneErr) {
toast.error(phoneErr);
return;
}
setSavingIdentity(true);
try {
const res = await http.put<{ user: typeof user }>("/api/me/account", {
name: displayName.trim(),
email: email.trim().toLowerCase(),
phone: phone.trim(),
});
setUser(res.user);
setDisplayName(res.user.name ?? "");
setEmail(res.user.email ?? "");
setPhone(res.user.phone ?? "");
setIdentityDirty(false);
toast.success("مشخصات ذخیره شد");
} catch (e) {
toast.error(toUserError(e, "ذخیره مشخصات ممکن نشد"));
} finally {
setSavingIdentity(false);
}
};
const changeUsername = async () => {
const err = validateUsername(nextUsername);
if (err) {
toast.error(err);
return;
}
const next = nextUsername.trim().toLowerCase();
if (next === user.username) {
toast.success("نام کاربری همان است");
return;
}
setSavingUsername(true);
try {
const res = await http.put<{ user: typeof user }>("/api/me/username", { username: next });
setUser(res.user);
setNextUsername("");
toast.success("نام کاربری تغییر کرد");
} catch (e) {
if (!(e instanceof ApiError && e.status === 429)) {
toast.error(toUserError(e, "تغییر نام کاربری ممکن نشد"));
}
} finally {
setSavingUsername(false);
}
};
const savePassword = async () => {
const err = validatePassword(password);
if (err) {
toast.error(err);
return;
}
setSavingPassword(true);
try {
const res = await http.put<{ user: typeof user }>("/api/me/account", {
currentPassword,
password,
});
setUser(res.user);
setPassword("");
setCurrentPassword("");
toast.success("گذرواژه ذخیره شد");
} catch (e) {
toast.error(toUserError(e, "تغییر گذرواژه ممکن نشد"));
} finally {
setSavingPassword(false);
}
};
const freezeAccount = async () => {
if (!freezeConfirm) {
setFreezeConfirm(true);
return;
}
if (!freezePassword.trim()) {
toast.error("گذرواژهٔ فعلی را بنویسید");
return;
}
setFreezeBusy(true);
try {
const res = await http.post<{ user: typeof user }>("/api/me/freeze", { currentPassword: freezePassword });
setUser(res.user);
setFreezeConfirm(false);
setFreezePassword("");
await qc.invalidateQueries({ queryKey: ["me"] });
toast.success("حساب فریز شد");
} catch (e) {
toast.error(toUserError(e, "فریز حساب ممکن نشد"));
} finally {
setFreezeBusy(false);
}
};
const unfreezeAccount = async () => {
setFreezeBusy(true);
try {
const res = await http.post<{ user: typeof user }>("/api/me/unfreeze");
setUser(res.user);
await qc.invalidateQueries({ queryKey: ["me"] });
toast.success("فریز لغو شد");
} catch (e) {
toast.error(toUserError(e, "لغو فریز ممکن نشد"));
} finally {
setFreezeBusy(false);
}
};
const closeDate = formatFreezeCloseDate(user.closesAt || freezeCloseIso(user.frozenAt));
return (
<div className="mx-auto max-w-4xl">
<header className="mb-6 space-y-1">
<h1 className="text-2xl font-black tracking-tight">تنظیمات</h1>
<p className="text-sm text-muted-foreground">حساب، حریم و داده‌های همین حساب.</p>
</header>
<SectionColumns>
<SectionNav
label="بخش‌های تنظیمات"
groups={[{ items: SECTIONS.map((item) => ({ id: item.id, label: item.label })) }]}
activeId={section}
onSelect={setSection}
/>
<SectionPanel
id="settings-section"
titleId="settings-section-title"
title={active.title}
description={active.description}
>
{section === "account" && (
<div className="space-y-8">
<AccountIdentityFields
idPrefix="settings"
name={displayName}
email={email}
phone={phone}
onName={(value) => {
setIdentityDirty(true);
setDisplayName(value);
}}
onEmail={(value) => {
setIdentityDirty(true);
setEmail(value);
}}
onPhone={(value) => {
setIdentityDirty(true);
setPhone(value);
}}
action={
<Button type="button" disabled={savingIdentity} onClick={() => void saveIdentity()}>
ذخیره مشخصات
</Button>
}
/>
<AccountUsernameSection
mode="self"
idPrefix="settings"
current={user.username}
next={nextUsername}
onNext={setNextUsername}
disabled={usernameCooling}
hint={
usernameCooling && user.usernameCooldownUntil ? (
<p className="text-sm text-muted-foreground">
سه بار نام کاربری را عوض کرده‌اید. تغییر بعدی از {formatJalaliStamp(user.usernameCooldownUntil)} ممکن است.
</p>
) : usernameAllowanceLabel(usernameRemaining) ? (
<p className="text-xs font-bold text-muted-foreground">{usernameAllowanceLabel(usernameRemaining)}</p>
) : null
}
action={
<Button
type="button"
disabled={usernameCooling || savingUsername || usernameRemaining <= 0}
onClick={() => void changeUsername()}
>
تغییر نام کاربری
</Button>
}
/>
<section className="space-y-4 border-t border-border pt-6" aria-labelledby="settings-password-heading">
<div className="space-y-1">
<h3 id="settings-password-heading" className="text-sm font-black">گذرواژه</h3>
<p className="text-xs text-muted-foreground">برای ذخیره، گذرواژهٔ فعلی لازم است.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field id="settings-new-password" label="گذرواژهٔ جدید">
<PasswordInput
id="settings-new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
dir="ltr"
autoComplete="new-password"
/>
</Field>
<Field id="settings-current-password" label="گذرواژهٔ فعلی">
<PasswordInput
id="settings-current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
dir="ltr"
autoComplete="current-password"
/>
</Field>
</div>
<Button type="button" disabled={savingPassword} onClick={() => void savePassword()}>
ذخیره
</Button>
</section>
<section className="space-y-3 border-t border-border pt-6" aria-labelledby="settings-freeze-heading">
<h3 id="settings-freeze-heading" className="text-sm font-black">فریز حساب</h3>
<p className="text-sm text-muted-foreground">
فریز تا ۳۰ روز قابل بازگشت است، بعد از آن حساب بسته می‌شود و حذف دائمی فقط با مجوز ادمین است.
</p>
{user.isClosed ? (
<p className="text-sm font-bold">حساب بسته شده است. لغو فریز ممکن نیست.</p>
) : user.isFrozen ? (
<div className="space-y-3">
<p className="text-sm">
{closeDate ? `این حساب در ${closeDate} بسته می‌شود.` : "این حساب تا ۳۰ روز دیگر بسته می‌شود."}
</p>
<Button type="button" variant="outline" disabled={freezeBusy} onClick={() => void unfreezeAccount()}>
لغو فریز
</Button>
</div>
) : (
<div className="space-y-3">
{freezeConfirm && (
<>
<p className="text-sm font-bold">برای فریز، گذرواژهٔ فعلی را بنویسید.</p>
<Field id="settings-freeze-password" label="گذرواژهٔ فعلی">
<PasswordInput
id="settings-freeze-password"
value={freezePassword}
onChange={(e) => setFreezePassword(e.target.value)}
dir="ltr"
autoComplete="current-password"
/>
</Field>
</>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={freezeConfirm ? "destructive" : "outline"} disabled={freezeBusy} onClick={() => void freezeAccount()}>
فریز حساب
</Button>
{freezeConfirm && (
<Button
type="button"
variant="ghost"
disabled={freezeBusy}
onClick={() => {
setFreezeConfirm(false);
setFreezePassword("");
}}
>
انصراف
</Button>
)}
</div>
</div>
)}
</section>
</div>
)}
{section === "privacy" && (
<PrivacyFields
form={form}
loading={profileQ.isPending}
error={profileQ.isError ? toUserError(profileQ.error, "بارگذاری حریم ممکن نشد") : null}
onPatch={patch}
onSave={() => void saveProfile()}
/>
)}
{section === "stats" && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile
icon={<Sparkles className="h-4 w-4 text-gold" />}
label="امتیاز"
value={formatFaNumber(user.xp)}
/>
<StatTile
icon={<Flame className="h-4 w-4 text-accent" />}
label="زنجیرهٔ فعلی"
value={formatStreakLabel(user.streakCurrent)}
/>
<StatTile
icon={<Flame className="h-4 w-4 text-muted-foreground" />}
label="بلندترین زنجیره"
value={formatStreakLabel(user.streakLongest)}
/>
<StatTile
icon={<CalendarDays className="h-4 w-4 text-primary" />}
label="روزهای این هفته"
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.weekDays)}
/>
<StatTile
icon={<CalendarDays className="h-4 w-4 text-secondary" />}
label="روزهای این ماه"
value={countLabel(activityOn, activityQ.isPending, activityQ.data?.monthDays)}
/>
</div>
<div>
<h3 className="text-sm font-black">نشان‌ها</h3>
{badgesOn ? (
<div className="mt-3">
<p className="mb-3 text-xs text-muted-foreground">رنگ‌دارها باز شده‌اند و کمرنگ‌ها هنوز قفل‌اند.</p>
{badgesQ.isPending ? (
<div className="flex flex-wrap gap-2" aria-busy="true" aria-label="در حال بارگذاری نشان‌ها">
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-28 rounded-full" />
</div>
) : badgesQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(badgesQ.error, "بارگذاری نشان‌ها ممکن نشد")}</p>
) : (badgesQ.data?.achievements ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">نشانی ثبت نشده.</p>
) : (
<ul className="flex flex-wrap gap-2">
{(badgesQ.data?.achievements ?? []).map((b) => (
<li key={b.id}>
<Badge variant={b.unlocked ? "success" : "secondary"} className="rounded-full px-2.5 py-1">
{b.title}
</Badge>
</li>
))}
</ul>
)}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">نمایش نشان‌ها در بخش حریم خاموش است.</p>
)}
</div>
</div>
)}
{section === "export" && (
<div className="space-y-6">
{exportChatsQ.isPending ? (
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری گفتگوها">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
) : exportChatsQ.isError ? (
<p className="text-sm font-bold text-destructive">{toUserError(exportChatsQ.error, "بارگذاری گفتگوها ممکن نشد")}</p>
) : exportChats.length === 0 ? (
<p className="text-sm text-muted-foreground">گفتگویی نیست.</p>
) : (
<div className="space-y-4">
<ul className="divide-y divide-border">
{exportPageChats.map((chat, offset) => {
const saved = chat.partner.saved || chat.partner.id === user.id;
const label = saved ? "پیام‌های ذخیره‌شده" : chat.partner.name;
const size = exportSizes[exportPageStart + offset];
const last = chat.lastMessage?.createdAt || size?.data?.lastAt;
return (
<li key={chat.partner.id} className="flex flex-col gap-3 py-4 first:pt-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-bold">{label}</p>
<p className="text-xs text-muted-foreground">
{last ? `آخرین فعالیت: ${formatJalaliStamp(last)}` : "هنوز پیامی نیست"}
</p>
<p className="text-xs font-bold text-foreground">
{size?.isPending
? "در حال برآورد حجم…"
: size?.isError
? "برآورد حجم ممکن نشد"
: `حجم تقریبی: ${formatApproxSize(size?.data?.bytes ?? 0)}`}
</p>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
disabled={exportingId === chat.partner.id}
onClick={() => {
setExportingId(chat.partner.id);
void downloadChatExport(chat.partner.id, exportFileName(label, chat.partner.id)).finally(() => {
setExportingId((cur) => (cur === chat.partner.id ? null : cur));
});
}}
>
دانلود
</Button>
</li>
);
})}
</ul>
{exportChats.length > EXPORT_PAGE_SIZE && (
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={exportPageSafe <= 1}
onClick={() => setExportPage(exportPageSafe - 1)}
>
قبلی
</Button>
<p className="text-sm font-bold tabular-nums text-muted-foreground">
{formatFaNumber(exportPageSafe)} از {formatFaNumber(exportPageCount)}
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={exportPageSafe >= exportPageCount}
onClick={() => setExportPage(exportPageSafe + 1)}
>
بعدی
</Button>
</div>
)}
</div>
)}
<ul className="divide-y divide-border border-t border-border pt-4">
<li className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<p className="text-sm font-bold">حذف حافظه موقت</p>
<p className="text-xs leading-relaxed text-muted-foreground">عکس و صدای ذخیره‌شده روی همین دستگاه حذف می‌شود.</p>
</div>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={() => {
void clearMediaCache().then(() => toast.success("حافظه موقت حذف شد"));
}}
>
حذف
</Button>
</li>
</ul>
</div>
)}
</SectionPanel>
</SectionColumns>
</div>
);
}
function PrivacyFields({
form,
loading,
error,
onPatch,
onSave,
}: {
form: Extras | null;
loading: boolean;
error: string | null;
onPatch: (partial: Partial<Extras>) => void;
onSave: () => void;
}) {
if (loading || !form) {
if (error) return <p className="text-sm font-bold text-destructive">{error}</p>;
return (
<div className="space-y-3" aria-busy="true" aria-label="در حال بارگذاری حریم">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
</div>
);
}
const links = form.socialLinks;
return (
<div className="space-y-6">
<div className="divide-y divide-border">
<PrivacyRow
id="privacy-public"
label="پروفایل عمومی"
hint="تا وقتی خاموش باشد، دیگران فقط نام و آواتار را می‌بینند."
checked={form.public}
onCheckedChange={(v) => onPatch({ public: v })}
/>
<PrivacyRow
id="privacy-activity"
label="نمایش فعالیت"
hint="روزهای فعال در پروفایل عمومی دیده می‌شود."
checked={form.showActivity}
onCheckedChange={(v) => onPatch({ showActivity: v })}
/>
<PrivacyRow
id="privacy-stats"
label="نمایش آمار"
hint="امتیاز و زنجیره در پروفایل عمومی دیده می‌شود."
checked={form.showStats}
onCheckedChange={(v) => onPatch({ showStats: v })}
/>
<PrivacyRow
id="privacy-path"
label="نمایش مسیر"
hint="مسیر یادگیری در پروفایل عمومی دیده می‌شود."
checked={form.showPath}
onCheckedChange={(v) => onPatch({ showPath: v })}
/>
<PrivacyRow
id="privacy-badges"
label="نمایش نشان‌ها"
hint="نشان‌ها در پروفایل عمومی دیده می‌شوند."
checked={form.showBadges}
onCheckedChange={(v) => onPatch({ showBadges: v })}
/>
</div>
<div className="space-y-3 border-t border-border pt-5">
<div>
<h3 className="text-sm font-black">لینک‌های شخصی</h3>
<p className="mt-1 text-xs text-muted-foreground">حداکثر پنج لینک. نام و نشانی را وارد کنید.</p>
</div>
{links.length === 0 ? (
<p className="text-sm text-muted-foreground">هنوز لینکی اضافه نشده.</p>
) : (
<ul className="space-y-3">
{links.map((link, i) => (
<li key={i} className="flex items-end gap-2">
<div className="w-28 shrink-0 space-y-1.5 sm:w-36">
<Label htmlFor={`settings-link-name-${i}`} className="text-xs font-bold text-muted-foreground">
نام
</Label>
<Input
id={`settings-link-name-${i}`}
value={link.name}
placeholder="تلگرام"
onChange={(e) => {
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row));
onPatch({ socialLinks });
}}
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<Label htmlFor={`settings-link-url-${i}`} className="text-xs font-bold text-muted-foreground">
نشانی
</Label>
<Input
id={`settings-link-url-${i}`}
dir="ltr"
value={link.url}
placeholder="https://"
onChange={(e) => {
const socialLinks = links.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row));
onPatch({ socialLinks });
}}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-11 shrink-0"
aria-label={`حذف لینک ${link.name || i + 1}`}
onClick={() => onPatch({ socialLinks: links.filter((_, idx) => idx !== i) })}
>
حذف
</Button>
</li>
))}
</ul>
)}
{links.length < 5 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onPatch({ socialLinks: [...links, { name: "", url: "" }] })}
>
افزودن لینک
</Button>
)}
</div>
<Button type="button" onClick={onSave}>
ذخیره حریم
</Button>
</div>
);
}
function PrivacyRow({
id,
label,
hint,
checked,
onCheckedChange,
}: {
id: string;
label: string;
hint: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
}) {
const hintId = `${id}-hint`;
return (
<div className="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0">
<div className="min-w-0 space-y-1">
<Label htmlFor={id} className="text-sm font-bold text-foreground">
{label}
</Label>
<p id={hintId} className="text-xs leading-relaxed text-muted-foreground">
{hint}
</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} aria-describedby={hintId} />
</div>
);
}
function formatFreezeCloseDate(iso?: string) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("fa-IR", { year: "numeric", month: "long", day: "numeric" });
}
function freezeCloseIso(frozenAt?: string) {
if (!frozenAt) return "";
const start = new Date(frozenAt);
if (Number.isNaN(start.getTime())) return "";
const close = new Date(start);
close.setDate(close.getDate() + 30);
return close.toISOString();
}
function usernameAllowanceLabel(remaining: number): string {
if (remaining >= 3) return "۳ بار دیگر";
if (remaining === 2) return "۲ بار دیگر";
if (remaining === 1) return "۱ بار دیگر";
return "";
}
function Field({ id, label, children }: { id: string; label: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<Label htmlFor={id} className="text-xs font-bold text-muted-foreground">
{label}
</Label>
{children}
</div>
);
}
function StatTile({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/30 px-3 py-3 sm:px-4">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-card">{icon}</span>
<div className="min-w-0">
<p className="text-[11px] font-bold text-muted-foreground">{label}</p>
<p className="text-lg font-black tabular-nums">{value}</p>
</div>
</div>
);
}
function countLabel(enabled: boolean, pending: boolean, value: number | undefined) {
if (!enabled) return "خاموش";
if (pending) return "…";
return formatFaNumber(value ?? 0);
}
function normalizeLinks(links: SocialLink[] | null | undefined): SocialLink[] {
if (!Array.isArray(links)) return [];
return links.slice(0, 5).map((row) => ({ name: row?.name ?? "", url: row?.url ?? "" }));
}
function formatApproxSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes < 1024) return `حدود ${formatFaNumber(1)} کیلوبایت`;
if (bytes < 1024 * 1024) return `حدود ${formatFaNumber(Math.round(bytes / 1024))} کیلوبایت`;
const mb = bytes / (1024 * 1024);
return `حدود ${formatFaNumber(mb, mb < 10 ? 1 : 0)} مگابایت`;
}
function exportFileName(label: string, id: number) {
const clean = label.replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim();
return `${clean || `chat-${id}`}.zip`;
}
async function downloadChatExport(chatId: number, filename: string) {
const token = useAuth.getState().accessToken;
const base = (process.env.NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
try {
const res = await fetch(`${base}/api/me/export?chat=${chatId}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "include",
});
if (!res.ok) {
toast.error("دانلود خروجی گفتگو ممکن نشد");
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("دانلود خروجی گفتگو ممکن نشد");
}
}
+105 -50
View File
@@ -1,8 +1,9 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { useRouter, useSearchParams } from "next/navigation";
import { Award, CalendarDays, HeartHandshake, MessageSquareHeart, Sparkles } from "lucide-react";
import { Award, CalendarDays, MessageSquareHeart, Share2, Sparkles } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/page-header";
@@ -17,7 +18,9 @@ import { Badge } from "@/components/ui/badge";
import type { Mentor } from "@/lib/types";
import { http } from "@/lib/api";
import type { ChatMessage, Conversation } from "@/lib/types";
import { cn } from "@/lib/utils";
import { cn, formatJalaliStamp } from "@/lib/utils";
import { useAuth } from "@/lib/auth-store";
import { isVerifiedRole, VerifiedBadge } from "@/components/verified-badge";
type Tab = "events" | "chats" | "leagues" | "community";
@@ -69,7 +72,7 @@ function HubTabs() {
جامعه، <span className="text-gradient-brand">استمرار و منتورها</span>
</>
}
description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو با منتور، و هماهنگی حمایت مالی."
description="رویدادهای زنده برای تقویت مهارت، لیگ‌های هفتگی برای ثبات، گفتگو، و شبکه‌های پرگار."
className="mb-0"
/>
@@ -88,26 +91,11 @@ function HubTabs() {
<span className="truncate">لیگ‌ها</span>
</TabsTrigger>
<TabsTrigger value="community" className="h-10 gap-1.5 px-2 sm:px-3">
<HeartHandshake className="h-4 w-4 shrink-0" />
<span className="truncate">حمایت</span>
<Share2 className="h-4 w-4 shrink-0" />
<span className="truncate">شبکه‌ها</span>
</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-0 space-y-4">
<button
type="button"
onClick={() => onTab("community")}
className="flex w-full items-center gap-3 rounded-2xl border-2 border-accent/30 bg-accent/5 px-4 py-3.5 text-start transition hover:bg-accent/10"
>
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-accent/15 text-accent">
<HeartHandshake className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-extrabold">حمایت مالی</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
هماهنگی دونیت از طریق گفتگو با تیم
</span>
</span>
</button>
<EventsPanel />
</TabsContent>
<TabsContent value="chats" className="mt-0">
@@ -128,17 +116,40 @@ function openChat(withId?: number) {
window.dispatchEvent(new CustomEvent("pargar:chat", { detail: { open: true, with: withId } }));
}
function roleWord(role?: string) {
if (role === "admin") return "مدیر";
if (role === "mentor") return "منتور";
if (role === "student") return "هنرجو";
return "کاربر";
}
function ChatsLauncher() {
const me = useAuth((s) => s.user);
const staff = me?.role === "admin" || me?.role === "mentor";
const [peoplePage, setPeoplePage] = useState(1);
const [alpha, setAlpha] = useState(false);
const [convPage, setConvPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ["mentors"],
queryFn: () => http.get<{ mentors: Mentor[] }>("/api/mentors"),
queryKey: ["mentors", peoplePage],
queryFn: () => http.get<{ mentors: Mentor[]; total: number; pageSize: number }>(`/api/mentors?page=${peoplePage}&pageSize=20`),
});
const { data: convData, isLoading: convLoading } = useQuery({
queryKey: ["conversations"],
queryFn: () => http.get<{ conversations: Conversation[] }>("/api/chats/conversations"),
queryKey: ["conversations", convPage],
queryFn: () =>
http.get<{ conversations: Conversation[]; total: number; pageSize: number }>(
`/api/chats/conversations?page=${convPage}&pageSize=10`,
),
});
const mentors = data?.mentors ?? [];
const people = useMemo(() => {
const list = [...mentors];
if (alpha) list.sort((a, b) => a.name.localeCompare(b.name, "fa"));
else list.sort((a, b) => Number(!!b.online) - Number(!!a.online) || a.name.localeCompare(b.name, "fa"));
return list;
}, [mentors, alpha]);
const conversations = convData?.conversations ?? [];
const peopleTotal = data?.total ?? mentors.length;
const convTotal = convData?.total ?? conversations.length;
return (
<div className="grid gap-4 lg:grid-cols-2">
@@ -156,7 +167,7 @@ function ChatsLauncher() {
</div>
</Panel>
<Panel title="گفتگوهای شما" description="مکالمات جاری با منتورها — روی هر کدام بزنید تا باز شود.">
<Panel title="گفتگوهای شما" description="مکالمات جاری — روی هر کدام بزنید تا باز شود.">
<div className="space-y-1.5 pt-1">
{convLoading && (
<div className="space-y-2">
@@ -183,9 +194,12 @@ function ChatsLauncher() {
<UserAvatar name={c.partner.name} className="h-10 w-10" {...avatarPropsOf(c.partner)} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-bold">{c.partner.name}</p>
<p className="flex min-w-0 items-center gap-1 font-bold">
<span className="truncate">{c.partner.name}</span>
{isVerifiedRole(c.partner.role) && <VerifiedBadge role={c.partner.role} />}
</p>
{c.lastMessage && (
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo(c.lastMessage.createdAt)}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{formatJalaliStamp(c.lastMessage.createdAt)}</span>
)}
</div>
<p
@@ -207,13 +221,38 @@ function ChatsLauncher() {
))}
{!convLoading && conversations.length === 0 && (
<p className="px-1 text-sm text-muted-foreground">
هنوز گفتگویی ندارید — از «شروع سریع» یک منتور انتخاب کنید.
هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید.
</p>
)}
{convTotal > (convData?.pageSize ?? 10) && (
<div className="flex items-center justify-between pt-2 text-xs">
<Button type="button" size="sm" variant="outline" disabled={convPage <= 1} onClick={() => setConvPage((p) => p - 1)}>
قبلی
</Button>
<span className="text-muted-foreground">{convPage}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={convPage * (convData?.pageSize ?? 10) >= convTotal}
onClick={() => setConvPage((p) => p + 1)}
>
بعدی
</Button>
</div>
)}
</div>
</Panel>
<Panel title="شروع سریع با منتور" description="یک گفتگو را مستقیم باز کنید.">
<Panel
title={staff ? "شروع گفتگو" : "شروع سریع با منتور"}
description="نقش هر نفر روی ردیف نوشته شده است."
actions={
<Button type="button" size="sm" variant="outline" onClick={() => setAlpha((v) => !v)}>
{alpha ? "آنلاین بالا" : "الفبایی"}
</Button>
}
>
<div className="space-y-1 pt-1">
{isLoading && (
<div className="space-y-2">
@@ -229,24 +268,46 @@ function ChatsLauncher() {
))}
</div>
)}
{mentors.map((m) => (
<button
{people.map((m) => (
<motion.button
layout
key={m.id}
onClick={() => openChat(m.id)}
className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5"
>
<UserAvatar name={m.name} className="h-10 w-10" {...avatarPropsOf(m)} />
<div className="min-w-0 flex-1">
<p className="truncate font-bold">{m.name}</p>
<p className="text-xs text-muted-foreground">منتور</p>
<p className="flex min-w-0 items-center gap-1 font-bold">
<span className="truncate">{m.name}</span>
{isVerifiedRole(m.role) && <VerifiedBadge role={m.role} />}
</p>
<p className="text-xs text-muted-foreground">{roleWord(m.role)}</p>
</div>
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" /> در دسترس
<span className={cn("inline-flex items-center gap-1.5 text-xs font-bold", m.online ? "text-success" : "text-muted-foreground")}>
<span className={cn("h-1.5 w-1.5 rounded-full", m.online ? "bg-success" : "bg-muted-foreground/40")} />
{m.online ? "آنلاین" : "آفلاین"}
</span>
</button>
</motion.button>
))}
{!isLoading && mentors.length === 0 && (
<p className="text-sm text-muted-foreground">هنوز منتوری در دسترس نیست.</p>
<p className="text-sm text-muted-foreground">{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}</p>
)}
{peopleTotal > (data?.pageSize ?? 20) && (
<div className="flex items-center justify-between pt-2 text-xs">
<Button type="button" size="sm" variant="outline" disabled={peoplePage <= 1} onClick={() => setPeoplePage((p) => p - 1)}>
قبلی
</Button>
<span className="text-muted-foreground">{peoplePage}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={peoplePage * (data?.pageSize ?? 20) >= peopleTotal}
onClick={() => setPeoplePage((p) => p + 1)}
>
بعدی
</Button>
</div>
)}
</div>
</Panel>
@@ -256,19 +317,13 @@ function ChatsLauncher() {
function messagePreview(m: ChatMessage) {
if (m.body) return m.body;
if (m.attachment) {
const atts = m.attachments?.length ? m.attachments : m.attachment ? [m.attachment] : [];
if (atts.length > 1 && atts.every((a) => a.type === "image")) return `📷 ${atts.length.toLocaleString("fa-IR")} عکس`;
if (atts.length > 1 && atts.every((a) => a.type === "file")) return `📎 ${atts.length.toLocaleString("fa-IR")} فایل`;
if (atts.length > 1) return `📎 ${atts.length.toLocaleString("fa-IR")} پیوست`;
if (atts[0]) {
const labels: Record<string, string> = { image: "📷 عکس", video: "🎬 ویدیو", audio: "🎵 پیام صوتی" };
return labels[m.attachment.type] ?? `📎 ${m.attachment.name}`;
return labels[atts[0].type] ?? `📎 ${atts[0].name}`;
}
return "";
}
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "الان";
if (m < 60) return `${m} دقیقه قبل`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ساعت قبل`;
return new Date(iso).toLocaleDateString("fa-IR");
}