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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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="بازگشت به خانه"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="بازگشت به خانه"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("دانلود خروجی گفتگو ممکن نشد");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user