"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("/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("account"); const [exportingId, setExportingId] = useState(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(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) => { 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 (

تنظیمات

حساب، حریم و داده‌های همین حساب.

({ id: item.id, label: item.label })) }]} activeId={section} onSelect={setSection} /> {section === "account" && (
{ setIdentityDirty(true); setDisplayName(value); }} onEmail={(value) => { setIdentityDirty(true); setEmail(value); }} onPhone={(value) => { setIdentityDirty(true); setPhone(value); }} action={ } /> سه بار نام کاربری را عوض کرده‌اید. تغییر بعدی از {formatJalaliStamp(user.usernameCooldownUntil)} ممکن است.

) : usernameAllowanceLabel(usernameRemaining) ? (

{usernameAllowanceLabel(usernameRemaining)}

) : null } action={ } />

گذرواژه

برای ذخیره، گذرواژهٔ فعلی لازم است.

setPassword(e.target.value)} dir="ltr" autoComplete="new-password" /> setCurrentPassword(e.target.value)} dir="ltr" autoComplete="current-password" />

فریز حساب

فریز تا ۳۰ روز قابل بازگشت است، بعد از آن حساب بسته می‌شود و حذف دائمی فقط با مجوز ادمین است.

{user.isClosed ? (

حساب بسته شده است. لغو فریز ممکن نیست.

) : user.isFrozen ? (

{closeDate ? `این حساب در ${closeDate} بسته می‌شود.` : "این حساب تا ۳۰ روز دیگر بسته می‌شود."}

) : (
{freezeConfirm && ( <>

برای فریز، گذرواژهٔ فعلی را بنویسید.

setFreezePassword(e.target.value)} dir="ltr" autoComplete="current-password" /> )}
{freezeConfirm && ( )}
)}
)} {section === "privacy" && ( void saveProfile()} /> )} {section === "stats" && (
} label="امتیاز" value={formatFaNumber(user.xp)} /> } label="زنجیرهٔ فعلی" value={formatStreakLabel(user.streakCurrent)} /> } label="بلندترین زنجیره" value={formatStreakLabel(user.streakLongest)} /> } label="روزهای این هفته" value={countLabel(activityOn, activityQ.isPending, activityQ.data?.weekDays)} /> } label="روزهای این ماه" value={countLabel(activityOn, activityQ.isPending, activityQ.data?.monthDays)} />

نشان‌ها

{badgesOn ? (

رنگ‌دارها باز شده‌اند و کمرنگ‌ها هنوز قفل‌اند.

{badgesQ.isPending ? (
) : badgesQ.isError ? (

{toUserError(badgesQ.error, "بارگذاری نشان‌ها ممکن نشد")}

) : (badgesQ.data?.achievements ?? []).length === 0 ? (

نشانی ثبت نشده.

) : (
    {(badgesQ.data?.achievements ?? []).map((b) => (
  • {b.title}
  • ))}
)}
) : (

نمایش نشان‌ها در بخش حریم خاموش است.

)}
)} {section === "export" && (
{exportChatsQ.isPending ? (
) : exportChatsQ.isError ? (

{toUserError(exportChatsQ.error, "بارگذاری گفتگوها ممکن نشد")}

) : exportChats.length === 0 ? (

گفتگویی نیست.

) : (
    {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 (
  • {label}

    {last ? `آخرین فعالیت: ${formatJalaliStamp(last)}` : "هنوز پیامی نیست"}

    {size?.isPending ? "در حال برآورد حجم…" : size?.isError ? "برآورد حجم ممکن نشد" : `حجم تقریبی: ${formatApproxSize(size?.data?.bytes ?? 0)}`}

  • ); })}
{exportChats.length > EXPORT_PAGE_SIZE && (

{formatFaNumber(exportPageSafe)} از {formatFaNumber(exportPageCount)}

)}
)}
  • حذف حافظه موقت

    عکس و صدای ذخیره‌شده روی همین دستگاه حذف می‌شود.

)}
); } function PrivacyFields({ form, loading, error, onPatch, onSave, }: { form: Extras | null; loading: boolean; error: string | null; onPatch: (partial: Partial) => void; onSave: () => void; }) { if (loading || !form) { if (error) return

{error}

; return (
); } const links = form.socialLinks; return (
onPatch({ public: v })} /> onPatch({ showActivity: v })} /> onPatch({ showStats: v })} /> onPatch({ showPath: v })} /> onPatch({ showBadges: v })} />

لینک‌های شخصی

حداکثر پنج لینک. نام و نشانی را وارد کنید.

{links.length === 0 ? (

هنوز لینکی اضافه نشده.

) : (
    {links.map((link, i) => (
  • { const socialLinks = links.map((row, idx) => (idx === i ? { ...row, name: e.target.value } : row)); onPatch({ socialLinks }); }} />
    { const socialLinks = links.map((row, idx) => (idx === i ? { ...row, url: e.target.value } : row)); onPatch({ socialLinks }); }} />
  • ))}
)} {links.length < 5 && ( )}
); } function PrivacyRow({ id, label, hint, checked, onCheckedChange, }: { id: string; label: string; hint: string; checked: boolean; onCheckedChange: (v: boolean) => void; }) { const hintId = `${id}-hint`; return (

{hint}

); } 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 (
{children}
); } function StatTile({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { return (
{icon}

{label}

{value}

); } 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("دانلود خروجی گفتگو ممکن نشد"); } }