704 lines
32 KiB
TypeScript
704 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Users, Shield, Building2, Settings, LogOut, User, Key, KeyRound, Smartphone, Calendar, MapPin, Image as ImageIcon, Briefcase, Mail, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { useLanguage } from "@/components/language-provider";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import { RouteGuard } from "@/components/auth/route-guard";
|
|
import { getAccessToken, apiFetch } from "@/lib/api";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { registerPasskey } from "@/lib/webauthn";
|
|
|
|
interface UserAccount {
|
|
user_id: string;
|
|
email: string;
|
|
username?: string | null;
|
|
full_name: string | null;
|
|
given_name?: string | null;
|
|
family_name?: string | null;
|
|
trust_state?: string | null;
|
|
trust_score?: number | null;
|
|
skills?: string[];
|
|
birth_date?: string | null;
|
|
province?: string | null;
|
|
city?: string | null;
|
|
gender?: string | null;
|
|
avatar_url?: string | null;
|
|
status: string;
|
|
is_active: boolean;
|
|
mfa_enabled: boolean;
|
|
email_verified: boolean;
|
|
phone_verified: boolean;
|
|
phone: string | null;
|
|
created_at: string;
|
|
last_login_at: string | null;
|
|
passkeys?: any[];
|
|
sessions?: any[];
|
|
}
|
|
|
|
const SECTIONS = [
|
|
{
|
|
key: "identity",
|
|
labelFa: "هویت",
|
|
labelEn: "Identity",
|
|
icon: Users,
|
|
descFa: "اطلاعات هویتی و پروفایل عمومی حساب",
|
|
descEn: "Identity info and public profile",
|
|
},
|
|
{
|
|
key: "auth",
|
|
labelFa: "احراز هویت",
|
|
labelEn: "Authentication",
|
|
icon: Shield,
|
|
descFa: "اطلاعات ورود، دستگاهها و امنیت حسابتان را مدیریت کنید",
|
|
descEn: "Manage sign-in methods, devices and security",
|
|
},
|
|
{
|
|
key: "businesses",
|
|
labelFa: "کسبوکارها",
|
|
labelEn: "Businesses",
|
|
icon: Building2,
|
|
descFa: "سازمانها و کسبوکارهای متصل به حساب شما",
|
|
descEn: "Organizations connected to your account",
|
|
},
|
|
{
|
|
key: "bizManage",
|
|
labelFa: "مدیریت کسبوکار",
|
|
labelEn: "Business Management",
|
|
icon: Settings,
|
|
descFa: "ساخت، ویرایش و انتقال مالکیت کسبوکارها",
|
|
descEn: "Create, edit and transfer businesses",
|
|
},
|
|
];
|
|
|
|
export default function AccountCenterPage() {
|
|
const { lang } = useLanguage();
|
|
const t = (fa: string, en: string) => (lang === "fa" ? fa : en);
|
|
const Chev = lang === "fa" ? ChevronLeft : ChevronRight;
|
|
|
|
const { user: ctxUser, logout } = useAuth();
|
|
const [mounted, setMounted] = useState(false);
|
|
const token = getAccessToken();
|
|
const [activeTab, setActiveTab] = useState("identity");
|
|
const [loading, setLoading] = useState(false);
|
|
const [user, setUser] = useState<UserAccount | null>(null);
|
|
const [orgs, setOrgs] = useState<any[]>([]);
|
|
const [sessions, setSessions] = useState<any[]>([]);
|
|
const [products, setProducts] = useState<any[]>([]);
|
|
const [pkBusy, setPkBusy] = useState(false);
|
|
const [pkError, setPkError] = useState("");
|
|
const [editing, setEditing] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<Record<string, string>>({});
|
|
|
|
const startEdit = () => {
|
|
setForm({
|
|
full_name: user?.full_name || "",
|
|
given_name: user?.given_name || "",
|
|
family_name: user?.family_name || "",
|
|
username: user?.username || "",
|
|
province: user?.province || "",
|
|
city: user?.city || "",
|
|
gender: user?.gender || "",
|
|
birth_date: user?.birth_date || "",
|
|
skills: (user?.skills || []).join(", "),
|
|
});
|
|
setEditing(true);
|
|
};
|
|
|
|
const cancelEdit = () => setEditing(false);
|
|
|
|
const saveEdit = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const payload: any = { ...form };
|
|
payload.skills = form.skills
|
|
? form.skills
|
|
.split(",")
|
|
.map((s: string) => s.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
const res = await apiFetch("/auth/me/", {
|
|
method: "PATCH",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (res.ok) {
|
|
setUser(await res.json());
|
|
setEditing(false);
|
|
}
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// Keep the active tab in sync with the ?tab= query param so the URL reflects
|
|
// the selected sidebar section and supports back/forward navigation.
|
|
useEffect(() => {
|
|
const valid = new Set(SECTIONS.map((s) => s.key));
|
|
const syncFromUrl = () => {
|
|
const tab = new URLSearchParams(window.location.search).get("tab");
|
|
if (tab && valid.has(tab)) setActiveTab(tab);
|
|
};
|
|
syncFromUrl();
|
|
window.addEventListener("popstate", syncFromUrl);
|
|
return () => window.removeEventListener("popstate", syncFromUrl);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (ctxUser && !user) setUser(ctxUser as unknown as UserAccount);
|
|
}, [ctxUser, user]);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
if (!token) return;
|
|
setLoading(true);
|
|
try {
|
|
const userRes = await apiFetch("/auth/me/");
|
|
if (userRes.ok) setUser(await userRes.json());
|
|
const orgRes = await apiFetch("/organizations/");
|
|
if (orgRes.ok) {
|
|
const d = await orgRes.json();
|
|
setOrgs((d as any).results || d || []);
|
|
}
|
|
const sessRes = await apiFetch("/sessions/");
|
|
if (sessRes.ok) {
|
|
const d = await sessRes.json();
|
|
setSessions(Array.isArray(d) ? d : d.results || []);
|
|
}
|
|
const prodRes = await apiFetch("/products/");
|
|
if (prodRes.ok) {
|
|
const d = await prodRes.json();
|
|
setProducts(Array.isArray(d) ? d : d.results || []);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, [token]);
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
|
|
};
|
|
|
|
const handleAddPasskey = async () => {
|
|
if (!token) return;
|
|
setPkBusy(true);
|
|
setPkError("");
|
|
try {
|
|
await registerPasskey(token, "My Passkey");
|
|
const userRes = await apiFetch("/auth/me/");
|
|
if (userRes.ok) setUser(await userRes.json());
|
|
} catch (e: any) {
|
|
setPkError(e?.message || t("ثبت پاسکلی شکست خورد", "Passkey registration failed"));
|
|
} finally {
|
|
setPkBusy(false);
|
|
}
|
|
};
|
|
|
|
if (!mounted) return <div className="min-h-screen bg-[#f6f7fb]" />;
|
|
|
|
const current = SECTIONS.find((s) => s.key === activeTab);
|
|
|
|
return (
|
|
<RouteGuard>
|
|
<div dir={lang === "fa" ? "rtl" : "ltr"} className="min-h-screen bg-[#f6f7fb]">
|
|
<aside
|
|
className={cn(
|
|
"fixed inset-y-0 z-40 flex w-64 flex-col overflow-y-auto border-slate-200 bg-white",
|
|
lang === "fa" ? "right-0 border-l" : "left-0 border-r"
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2.5 border-b border-slate-100 px-5 py-4">
|
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-[#8b7cf6] to-[#6d5ef0] text-white shadow-md">
|
|
<Users className="h-5 w-5" />
|
|
</span>
|
|
<span className="text-sm font-bold text-slate-900">UserManager</span>
|
|
</div>
|
|
|
|
<nav className="flex-1 space-y-1 p-3">
|
|
{SECTIONS.map((sec) => {
|
|
const isActive = activeTab === sec.key;
|
|
return (
|
|
<button
|
|
key={sec.key}
|
|
onClick={() => {
|
|
setActiveTab(sec.key);
|
|
window.history.pushState(
|
|
{},
|
|
"",
|
|
`${window.location.pathname}?tab=${sec.key}`,
|
|
);
|
|
}}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition",
|
|
isActive
|
|
? "bg-violet-50 font-semibold text-[#6d5ef0]"
|
|
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
|
|
)}
|
|
>
|
|
<sec.icon className="h-[18px] w-[18px]" />
|
|
{t(sec.labelFa, sec.labelEn)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div className="space-y-3 border-t border-slate-100 p-4">
|
|
<div className="flex items-center justify-between px-1">
|
|
<span className="text-xs text-slate-400">{lang === "fa" ? "زبان" : "Language"}</span>
|
|
<button
|
|
onClick={() => {
|
|
localStorage.setItem("language", lang === "fa" ? "en" : "fa");
|
|
window.location.reload();
|
|
}}
|
|
className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 transition hover:bg-slate-200"
|
|
>
|
|
{lang === "fa" ? "EN" : "فا"}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex w-full items-center gap-2 rounded-xl px-3.5 py-2 text-sm text-rose-500 transition hover:bg-rose-50"
|
|
>
|
|
<LogOut className="h-4 w-4 rtl:rotate-180" />
|
|
{t("خروج از حساب", "Log out")}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className={cn("min-h-screen p-6 lg:p-10", lang === "fa" ? "pr-64 lg:pr-72" : "pl-64 lg:pl-72")}>
|
|
<header className="mb-8">
|
|
<h1 className="text-2xl font-extrabold tracking-tight text-[#1c1d3a]">{current ? t(current.labelFa, current.labelEn) : ""}</h1>
|
|
<p className="mt-1.5 text-sm text-slate-500">{current ? t(current.descFa, current.descEn) : ""}</p>
|
|
</header>
|
|
|
|
{activeTab === "identity" && (
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
{/* Profile summary card */}
|
|
<Card className="flex flex-col items-center py-8 text-center lg:col-span-1">
|
|
{user?.avatar_url ? (
|
|
<img src={user.avatar_url} alt="" className="h-24 w-24 rounded-full object-cover ring-4 ring-violet-50" />
|
|
) : (
|
|
<span className="flex h-24 w-24 items-center justify-center rounded-full bg-violet-50 text-[#6d5ef0] ring-4 ring-violet-50">
|
|
<User className="h-11 w-11" />
|
|
</span>
|
|
)}
|
|
<div className="mt-4 text-lg font-bold text-slate-900">{user?.full_name || "-"}</div>
|
|
<div className="mt-0.5 text-sm text-slate-500">{user?.email || ""}</div>
|
|
{user?.status === "active" && (
|
|
<span className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-600">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
|
{t("فعال", "Active")}
|
|
</span>
|
|
)}
|
|
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
|
|
{typeof user?.mfa_enabled === "boolean" && (
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${
|
|
user.mfa_enabled
|
|
? "bg-emerald-50 text-emerald-600"
|
|
: "bg-amber-50 text-amber-600"
|
|
}`}
|
|
>
|
|
<span className="h-1.5 w-1.5 rounded-full bg-current" />
|
|
{user.mfa_enabled
|
|
? t("احراز دومرحلهای فعال", "MFA enabled")
|
|
: t("احراز دومرحلهای غیرفعال", "MFA disabled")}
|
|
</span>
|
|
)}
|
|
{user?.trust_state && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-violet-50 px-3 py-1 text-xs font-medium text-[#6d5ef0]">
|
|
{t("سطح اعتماد", "Trust")}: {user.trust_state}
|
|
{typeof user.trust_score === "number" ? ` (${user.trust_score})` : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button className="mt-5 rounded-full border border-slate-200 px-4 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#6d5ef0]/40 hover:text-[#6d5ef0]">
|
|
{t("ویرایش تصویر", "Edit photo")}
|
|
</button>
|
|
</Card>
|
|
|
|
{/* Personal info card */}
|
|
<Card className="lg:col-span-2">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<h3 className="text-base font-bold text-slate-900">{t("اطلاعات شخصی", "Personal information")}</h3>
|
|
{editing ? (
|
|
<div className="flex shrink-0 gap-2">
|
|
<button
|
|
onClick={cancelEdit}
|
|
className="rounded-full border border-slate-200 px-3.5 py-1.5 text-xs font-medium text-slate-500 transition hover:border-slate-300"
|
|
>
|
|
{t("انصراف", "Cancel")}
|
|
</button>
|
|
<button
|
|
onClick={saveEdit}
|
|
disabled={saving}
|
|
className="rounded-full bg-[#6d5ef0] px-3.5 py-1.5 text-xs font-semibold text-white shadow-[0_6px_18px_rgba(109,94,240,0.35)] transition hover:bg-[#5747d8] disabled:opacity-50"
|
|
>
|
|
{saving ? "..." : t("ذخیره", "Save")}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={startEdit}
|
|
className="shrink-0 rounded-full border border-slate-200 px-3.5 py-1.5 text-xs font-medium text-slate-500 transition hover:border-[#6d5ef0]/40 hover:text-[#6d5ef0]"
|
|
>
|
|
{t("ویرایش اطلاعات", "Edit")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{loading ? (
|
|
<Loading />
|
|
) : editing ? (
|
|
<div className="mt-4 grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
|
<EditField label={t("نام کامل", "Full name")} value={form.full_name} onChange={(v: string) => setForm({ ...form, full_name: v })} />
|
|
<EditField label={t("نام", "First name")} value={form.given_name} onChange={(v: string) => setForm({ ...form, given_name: v })} />
|
|
<EditField label={t("نام خانوادگی", "Last name")} value={form.family_name} onChange={(v: string) => setForm({ ...form, family_name: v })} />
|
|
<EditField label={t("نام کاربری", "Username")} value={form.username} onChange={(v: string) => setForm({ ...form, username: v })} />
|
|
<EditField label={t("استان", "Province")} value={form.province} onChange={(v: string) => setForm({ ...form, province: v })} />
|
|
<EditField label={t("شهر", "City")} value={form.city} onChange={(v: string) => setForm({ ...form, city: v })} />
|
|
<EditField
|
|
label={t("جنسیت", "Gender")}
|
|
value={form.gender}
|
|
onChange={(v: string) => setForm({ ...form, gender: v })}
|
|
select
|
|
options={[
|
|
{ value: "", label: t("انتخاب نشده", "Not set") },
|
|
{ value: "male", label: t("مرد", "Male") },
|
|
{ value: "female", label: t("زن", "Female") },
|
|
]}
|
|
/>
|
|
<EditField label={t("تاریخ تولد", "Birth date")} type="date" value={form.birth_date} onChange={(v: string) => setForm({ ...form, birth_date: v })} />
|
|
<EditField
|
|
label={t("مهارتها", "Skills")}
|
|
value={form.skills}
|
|
onChange={(v: string) => setForm({ ...form, skills: v })}
|
|
full
|
|
hint={t("با کاما جدا کنید", "Comma separated")}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
|
<Field label={t("نام", "First name")} value={user?.given_name || user?.full_name?.split(" ")[0] || "-"} />
|
|
<Field label={t("نام خانوادگی", "Last name")} value={user?.family_name || user?.full_name?.split(" ").slice(1).join(" ") || "-"} />
|
|
<Field label={t("نام کاربری", "Username")} value={user?.username || "-"} />
|
|
<Field label={t("ایمیل", "Email")} value={user?.email || "-"} />
|
|
<Field label={t("شماره موبایل", "Phone")} value={user?.phone || "-"} />
|
|
<Field label={t("شهر / استان", "City / Province")} value={[user?.city, user?.province].filter(Boolean).join(" / ") || "-"} icon={MapPin} />
|
|
<Field label={t("جنسیت", "Gender")} value={genderLabel(user?.gender, t)} />
|
|
<Field label={t("تاریخ تولد", "Birth date")} value={user?.birth_date ? new Date(user.birth_date).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US") : "-"} icon={Calendar} />
|
|
<Field label={t("مهارتها", "Skills")} value={user?.skills?.length ? user.skills.join("، ") : "-"} full />
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "auth" && (
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHead title={t("روشهای ورود", "Sign-in methods")} />
|
|
<div className="mt-3 divide-y divide-slate-100">
|
|
<Row icon={Smartphone} title={t("شماره موبایل", "Phone number")} sub={user?.phone || "-"} verified={user?.phone_verified} chev />
|
|
<Row icon={Mail} title={t("ایمیل", "Email")} sub={user?.email || "-"} verified={user?.email_verified} chev />
|
|
</div>
|
|
<CardHead classTop="mt-6" title={t("تنظیمات امنیتی", "Security settings")} />
|
|
<div className="mt-3 divide-y divide-slate-100">
|
|
<Row icon={Shield} title={t("احراز هویت دومرحلهای", "Two-factor authentication")} toggleOn={!!user?.mfa_enabled} />
|
|
<Row icon={Key} title={t("ورود با دستگاه قابلاعتماد", "Trusted device login")} toggleOn />
|
|
<Row icon={KeyRound} title={t("مدیریت دستگاهها", "Manage devices")} button={t("مدیریت", "Manage")} onClick={handleAddPasskey} busy={pkBusy} />
|
|
</div>
|
|
{pkError && <p className="mt-3 text-xs text-rose-500">{pkError}</p>}
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHead title={t("نشستها و دستگاهها", "Sessions & devices")} />
|
|
<div className="mt-3 divide-y divide-slate-100">
|
|
{(sessions || []).slice(0, 5).map((s: any, i: number) => (
|
|
<div key={i} className="flex items-center gap-3 py-3.5">
|
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-50 text-slate-500">
|
|
<Smartphone className="h-5 w-5" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-slate-800">{s.device_name || t("دستگاه ناشناس", "Unknown device")}</div>
|
|
<div className="text-xs text-slate-400">
|
|
{s.ip_address || "—"} ·{" "}
|
|
{s.expires_at ? new Date(s.expires_at).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US") : "—"}
|
|
</div>
|
|
</div>
|
|
<span className={cn("inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-medium", s.status === "active" ? "bg-emerald-50 text-emerald-600" : "bg-slate-100 text-slate-500")}>
|
|
<span className={cn("h-1.5 w-1.5 rounded-full", s.status === "active" ? "bg-emerald-500" : "bg-slate-400")} />
|
|
{s.status === "active" ? t("فعال", "Active") : (s.status || t("غیرفعال", "Inactive"))}
|
|
</span>
|
|
<button className="rounded-lg p-1.5 text-slate-300 transition hover:bg-slate-50 hover:text-slate-500">
|
|
<Chev className="h-4 w-4 rtl:rotate-180" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{(!sessions || sessions.length === 0) && !loading && (
|
|
<p className="py-6 text-center text-sm text-slate-400">{t("نشست فعالی یافت نشد", "No active sessions found")}</p>
|
|
)}
|
|
</div>
|
|
<button className="mt-3 w-full rounded-xl border border-slate-200 py-2.5 text-xs font-medium text-slate-600 transition hover:border-[#6d5ef0]/40 hover:text-[#6d5ef0]">
|
|
{t("خروج از تمام نشستها", "Sign out of all sessions")}
|
|
</button>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "businesses" && (
|
|
<Card>
|
|
<CardHead title={t("سازمانهای من", "My organizations")} action={t("عضویت در سازمان", "Join organization")} />
|
|
{loading ? (
|
|
<Loading />
|
|
) : orgs.length === 0 ? (
|
|
<Empty text={t("هنوز عضو هیچ کسبوکاری نیستید", "You haven't joined any business yet")} icon={Building2} />
|
|
) : (
|
|
<div className="mt-4 divide-y divide-slate-100">
|
|
{orgs.map((o: any, i: number) => (
|
|
<div key={o.id || i} className="flex items-center gap-4 py-4">
|
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-violet-50 text-lg font-extrabold text-[#6d5ef0]">
|
|
{(o.name || "?").charAt(0)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-slate-900">{o.name || o.slug}</div>
|
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-slate-400">
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 px-2 py-0.5 font-medium text-[#6d5ef0]">
|
|
{roleLabel(o.role, t)}
|
|
</span>
|
|
<span>{t("عضو", "Member")}</span>
|
|
</div>
|
|
</div>
|
|
<div className="hidden text-left text-xs text-slate-400 sm:block" dir="ltr">
|
|
{o.joined_at ? new Date(o.joined_at).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US") : ""}
|
|
</div>
|
|
<button className="rounded-lg p-1.5 text-slate-300 transition hover:bg-slate-50 hover:text-slate-500">
|
|
<Chev className="h-4 w-4 rtl:rotate-180" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === "bizManage" && (
|
|
<div className="grid gap-6">
|
|
<Card>
|
|
<CardHead title={t("مدیریت کسبوکارها", "Business management")} />
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<ActionCard icon={Briefcase} title={t("ساخت کسبوکار جدید", "Create new business")} desc={t("نام برند، صنعت، استان و اطلاعات پایه را وارد کنید", "Enter brand name, industry, province and basics")} cta={t("+ ساخت کسبوکار جدید", "+ Create business")} primary />
|
|
<ActionCard icon={Settings} title={t("ویرایش کسبوکار", "Edit business")} desc={t("اطلاعات پایه، تماس و اطلاعات حقوقی را ویرایش کنید", "Edit basic, contact and legal information")} cta={t("انتخاب کسبوکار", "Pick a business")} />
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHead title={t("محصولات من", "My products")} desc={t("دسترسی به محصولات متصل به این حساب", "Access connected products of this account")} />
|
|
<div className="mt-4 space-y-3">
|
|
{products.length === 0 ? (
|
|
<p className="py-6 text-center text-sm text-slate-400">{t("هنوز به محصولی دسترسی ندارید", "You don't have access to any product yet")}</p>
|
|
) : (
|
|
products.map((p: any) => (
|
|
<div key={p.id || p.key} className="flex items-center gap-4 rounded-2xl border border-slate-100 p-4">
|
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-violet-50 text-base font-extrabold text-[#6d5ef0]">
|
|
{(p.name || p.key || "?").charAt(0)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-slate-900">{p.name || p.key}</div>
|
|
<div className="text-xs text-slate-400">{p.description || p.key}</div>
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="rounded-full border border-rose-100 text-xs text-rose-500 hover:bg-rose-50">
|
|
{t("حذف دسترسی", "Revoke access")}
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
<div className="mt-4 rounded-2xl bg-slate-50 p-4 text-xs leading-5 text-slate-400">
|
|
{t(
|
|
"دسترسی به محصولات تابع سیاست دسترسی کلی است.",
|
|
"Product access follows the global access policy."
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
<footer className="mt-14 pb-6 text-center text-xs text-slate-300">
|
|
© {new Date().getFullYear()} UserManager · MyAccount Hamsoo
|
|
</footer>
|
|
</main>
|
|
</div>
|
|
</RouteGuard>
|
|
);
|
|
}
|
|
|
|
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<div className={cn("rounded-3xl border border-slate-100 bg-white p-6 shadow-[0_1px_3px_rgba(16,24,40,0.05)] sm:p-7", className)}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CardHead({ title, desc, action, classTop }: { title: string; desc?: string; action?: string; classTop?: string }) {
|
|
return (
|
|
<div className={cn("flex items-start justify-between gap-4", classTop)}>
|
|
<div>
|
|
<h3 className="text-base font-bold text-slate-900">{title}</h3>
|
|
{desc && <p className="mt-0.5 text-xs text-slate-400">{desc}</p>}
|
|
</div>
|
|
{action && (
|
|
<button className="shrink-0 rounded-full border border-slate-200 px-3.5 py-1.5 text-xs font-medium text-slate-500 transition hover:border-[#6d5ef0]/40 hover:text-[#6d5ef0]">
|
|
{action}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, value, icon: Icon, full }: any) {
|
|
return (
|
|
<div className={cn(full && "sm:col-span-2")}>
|
|
<dt className="text-xs font-medium text-slate-400">{label}</dt>
|
|
<dd className="mt-1 flex items-center gap-1.5 text-sm font-medium text-slate-800">
|
|
{Icon && <Icon className="h-3.5 w-3.5 text-slate-300" />}
|
|
{value}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditField({
|
|
label,
|
|
value,
|
|
onChange,
|
|
type,
|
|
full,
|
|
hint,
|
|
select,
|
|
options,
|
|
}: any) {
|
|
const base =
|
|
"w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-[#6d5ef0]/50 focus:ring-2 focus:ring-[#6d5ef0]/15";
|
|
return (
|
|
<div className={cn(full && "sm:col-span-2")}>
|
|
<label className="mb-1.5 block text-xs font-medium text-slate-400">{label}</label>
|
|
{select ? (
|
|
<select className={base} value={value} onChange={(e) => onChange(e.target.value)}>
|
|
{(options || []).map((o: any) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={type || "text"}
|
|
className={base}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
)}
|
|
{hint && <p className="mt-1 text-[11px] text-slate-400">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ icon: Icon, title, sub, verified, toggleOn, chev, button, onClick, busy }: any) {
|
|
return (
|
|
<div className="flex items-center gap-3 py-3.5">
|
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-slate-50 text-slate-500">
|
|
<Icon className="h-5 w-5" />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-slate-800">{title}</div>
|
|
{sub && <div className="truncate text-xs text-slate-400">{sub}</div>}
|
|
</div>
|
|
{verified !== undefined && (
|
|
<span className={cn("inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11px] font-medium", verified ? "bg-emerald-50 text-emerald-600" : "bg-amber-50 text-amber-600")}>
|
|
{!verified && "! "}
|
|
{verified ? "تأیید شده ✓" : "تأیید نشده"}
|
|
</span>
|
|
)}
|
|
{toggleOn !== undefined && (
|
|
<span className={cn("relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition", toggleOn ? "bg-[#6d5ef0]" : "bg-slate-200")}>
|
|
<span className={cn("absolute h-5 w-5 rounded-full bg-white shadow transition-all", toggleOn ? "right-[2px] rtl:left-[2px] rtl:right-auto" : "left-[2px]")} />
|
|
</span>
|
|
)}
|
|
{chev && <ChevronRight className="h-4 w-4 text-slate-300 rtl:rotate-180" />}
|
|
{button && (
|
|
<button onClick={onClick} disabled={busy} className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-500 transition hover:border-[#6d5ef0]/40 hover:text-[#6d5ef0] disabled:opacity-50">
|
|
{busy ? "..." : button}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionCard({ icon: Icon, title, desc, cta, primary }: any) {
|
|
return (
|
|
<div className="rounded-2xl border border-slate-100 p-5 transition hover:shadow-md">
|
|
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-violet-50 text-[#6d5ef0]">
|
|
<Icon className="h-5 w-5" />
|
|
</span>
|
|
<div className="mt-3 text-sm font-bold text-slate-900">{title}</div>
|
|
<p className="mt-1 text-xs leading-5 text-slate-400">{desc}</p>
|
|
<button
|
|
className={cn(
|
|
"mt-4 rounded-full px-4 py-1.5 text-xs font-semibold transition",
|
|
primary
|
|
? "bg-[#6d5ef0] text-white shadow-[0_6px_18px_rgba(109,94,240,0.35)] hover:bg-[#5747d8]"
|
|
: "border border-slate-200 text-slate-600 hover:border-[#6d5ef0]/40 hover:text-[#6d5ef0]"
|
|
)}
|
|
>
|
|
{cta}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Empty({ text, icon: Icon }: any) {
|
|
return (
|
|
<div className="flex flex-col items-center py-14 text-center">
|
|
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-50 text-slate-300">
|
|
<Icon className="h-7 w-7" />
|
|
</span>
|
|
<p className="mt-3 text-sm text-slate-400">{text}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Loading() {
|
|
return (
|
|
<div className="animate-pulse space-y-3 py-6">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="h-10 rounded-xl bg-slate-50" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function genderLabel(g: string | null | undefined, t: (fa: string, en: string) => string) {
|
|
if (!g) return "-";
|
|
if (g === "male") return t("مرد", "Male");
|
|
if (g === "female") return t("زن", "Female");
|
|
return g;
|
|
}
|
|
|
|
function roleLabel(r: string | undefined | null, t: (fa: string, en: string) => string) {
|
|
const v = (r || "").toLowerCase();
|
|
if (v === "owner") return t("مالک", "Owner");
|
|
if (v === "admin") return t("مدیر", "Admin");
|
|
return t("عضو", "Member");
|
|
}
|