Vitron-Front/app/routes/profile._index.tsx
Arda Samadi a642fc0fae feat(seller-team): multi-user dashboard — store switcher, role gating, team UI
- useMyStores + team hooks (invite by phone / list / remove) in use-seller-hooks.
- useStoreOwnership rewritten to allow staff (not just the owner) via my-stores;
  exposes `role` so callers can gate owner-only UI, and no longer redirects
  members away from a store they help manage.
- Store settings: owner-only items (financial, shipping, edit store, instagram
  sync, team) hidden from staff; new "مدیریت اعضای تیم" entry (owner-only).
- New /store/:storeId/team page: invite by phone + members list with pending/
  active status + remove (owner-only, redirects staff). MyLayout keeps store mode
  on the team route.
- StoreForm edit is owner-only (redirects staff). Profile "enter my store" now
  routes owners to their store and staff to the store they manage.
- Chat already split earlier: seller inbox vs personal buyer inbox.

Depends on the seller-team backend; ships on this branch, not main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 16:43:53 +03:30

573 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { MetaFunction, redirect, type LoaderFunctionArgs } from "@remix-run/node";
import { sessionStorage } from "~/sessions.server";
import {
BookmarkMinus,
ChevronLeft,
MapPin,
Wallet,
MessageCircle,
Store,
ShoppingBag,
CircleFadingPlus,
Headset,
Info,
LogOut,
CircleUserRound,
FileQuestion,
Layers,
Pencil,
} from "lucide-react";
import { Link, useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "~/utils/RequestHandler";
import { useMyStores } from "~/requestHandler/use-seller-hooks";
import { useServiceGetWallet } from "~/requestHandler/use-wallet-hooks";
import { useBadgeCounts } from "~/hooks/useBadgeCounts";
import { Skeleton } from "~/components/ui/skeleton";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Dialog, DialogContent, DialogOverlay } from "~/components/ui/dialog";
import { useRootData } from "~/hooks/use-root-data";
import { useServiceGetFollowedSellers } from "~/requestHandler/use-following-hooks";
import { User } from "~/utils/token-manager.server";
import { SunMoonToggle } from "~/components/SunMoonToggle";
import { LoginRequiredDialog } from "~/components/LoginRequiredDialog";
interface MenuItemType {
title: string;
link?: string;
pageName: string;
hasBorder?: boolean;
image: React.ReactNode;
badge?: number | null;
}
// Guard: the account hub requires login (sub-pages are already protected via
// validateTokens; this covers the /profile index itself).
export async function loader({ request }: LoaderFunctionArgs) {
const session = await sessionStorage.getSession(
request.headers.get("cookie")
);
const user = session.get("user");
if (!user?.access_token) {
throw redirect("/login");
}
return null;
}
export default function Profile() {
const { user, theme: initialTheme } = useRootData();
const [storeModalOpen, setStoreModalOpen] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false);
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
const { data: profileData } = useServiceGetProfile();
const { data: myStores } = useMyStores();
const { data: walletData } = useServiceGetWallet();
const { data: badgeCounts } = useBadgeCounts();
const { data: followedSellers, isLoading: isFollowedSellersLoading } =
useServiceGetFollowedSellers();
// Calculate the count of followed sellers
const followedCount = followedSellers?.length || 0;
const walletBalance = walletData?.balance
? new Intl.NumberFormat("fa-IR").format(parseInt(walletData.balance))
: "۰";
// Top quick-actions (also live in the menu, but surfaced as a grid)
const quickActions = [
{
label: "سفارش‌ها",
link: "/profile/orders",
icon: <ShoppingBag size={22} />,
badge: (badgeCounts?.order_updates || null) as number | null,
},
{
label: "نشان‌ها",
link: "/profile/bookmarks",
icon: <BookmarkMinus size={22} />,
badge: null,
},
{
label: "دنبال‌ها",
link: "/profile/following",
icon: <CircleFadingPlus size={22} />,
badge: followedCount || null,
},
{
label: "آدرس‌ها",
link: "/profile/location",
icon: <MapPin size={22} />,
badge: null,
},
];
// The menu list below drops the items surfaced as quick actions / wallet card.
const quickPages = new Set([
"orders",
"bookmarks",
"following",
"location",
"wallet",
]);
// Create dynamic items with the followed count
const menuItems: MenuItemType[] = [
{
title: "سفارش‌های من",
link: "/profile/orders",
pageName: "orders",
hasBorder: true,
image: <ShoppingBag size={20} />,
},
{
title: isFollowedSellersLoading
? "دنبال شده‌ها"
: `دنبال شده‌ها(${followedCount})`,
link: "/profile/following",
pageName: "following",
image: <CircleFadingPlus size={20} />,
},
{
title: "پیام‌ها",
link: "/profile/threads",
pageName: "messages",
image: <MessageCircle size={20} />,
badge: badgeCounts?.chat || null,
},
{
title: "کیف پول",
link: "/profile/wallet",
pageName: "wallet",
image: <Wallet size={20} />,
},
{
title: "آدرس های من",
link: "/profile/location",
pageName: "location",
image: <MapPin size={20} />,
},
{
title: "ورود به فروشگاه من",
link: "",
pageName: "store",
image: <Store size={20} />,
},
{
title: "ذخیره شده‌ها",
link: "/profile/bookmarks",
pageName: "bookmarks",
image: <BookmarkMinus size={20} />,
},
{
title: "ست‌های من",
link: "/profile/sets",
pageName: "sets",
image: <Layers size={20} />,
},
{
title: "تماس با پشتیبانی",
pageName: "support",
hasBorder: true,
image: <Headset size={20} />,
},
{
title: "سوالات متداول",
link: "/profile/faq",
pageName: "faq",
image: <FileQuestion size={20} />,
},
{
title: "درباره ویترون",
link: "/profile/about",
pageName: "about",
image: <Info size={20} />,
},
{
title: "خروج",
pageName: "logout",
link: "/logout",
image: <LogOut size={20} />,
},
];
const navigate = useNavigate();
return (
<div className="flex flex-col gap-0">
{/* Desktop: welcome (the sidebar provides the menu) */}
<div className="hidden lg:flex flex-col items-center justify-center text-center py-24 border border-WHITE3 rounded-2xl">
<CircleUserRound size={64} className="text-GRAY mb-4" />
<h2 className="text-[22px] font-extrabold mb-1">حساب کاربری</h2>
<p className="text-GRAY">یک گزینه را از منوی کناری انتخاب کنید</p>
</div>
{/* Mobile: account hub */}
<div className="flex flex-col gap-0 lg:hidden">
{/* Header: avatar + name + phone + edit */}
<div className="flex items-center gap-3.5 px-4 pt-4 pb-3">
<CircleUserRound size={56} className="text-GRAY shrink-0" />
<div className="min-w-0 flex-1">
<h2 className="text-B16 font-bold truncate">
{profileData?.username || "کاربر ویترون"}
</h2>
{profileData?.phoneNumber ? (
<p className="text-GRAY text-R12 mt-0.5" dir="ltr">
{profileData.phoneNumber}
</p>
) : null}
</div>
<Link
to="/profile/setting"
aria-label="ویرایش"
className="w-10 h-10 rounded-xl border border-WHITE3 grid place-items-center text-BLACK shrink-0"
>
<Pencil size={18} />
</Link>
</div>
{/* Wallet card */}
<Link
to="/profile/wallet"
className="mx-4 mt-1 rounded-[18px] p-5 bg-gradient-to-br from-[#14213d] to-VITROWN_BLUE text-white flex items-center"
>
<div className="flex-1 min-w-0">
<small className="opacity-80 text-R12">موجودی کیف پول</small>
<b className="block text-[22px] font-extrabold mt-1">
{walletBalance}
<span className="text-[13px] font-semibold"> تومان</span>
</b>
</div>
<span className="bg-white text-BLACK rounded-xl px-4 py-2.5 text-R12 font-bold shrink-0">
افزایش موجودی
</span>
</Link>
{/* Quick actions */}
<div className="grid grid-cols-4 gap-2.5 px-4 pt-5 pb-1">
{quickActions.map((qa) => (
<Link
key={qa.link}
to={qa.link}
className="flex flex-col items-center gap-1.5"
>
<div className="relative w-[52px] h-[52px] rounded-2xl bg-WHITE2 grid place-items-center text-BLACK">
{qa.badge ? (
<span className="absolute -top-1 -left-1 min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center border-2 border-WHITE">
{qa.badge}
</span>
) : null}
{qa.icon}
</div>
<span className="text-R12 text-BLACK2">{qa.label}</span>
</Link>
))}
</div>
<p className="text-R12 font-bold text-GRAY px-4 mt-4 mb-1">حساب من</p>
{/* Theme toggle */}
<div className="flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px]">
<p className="text-R12">
{initialTheme === "dark" ? "حالت تاریک" : "حالت روشن"}
</p>
<SunMoonToggle theme={initialTheme} />
</div>
<MenuItems
items={menuItems.filter((m) => !quickPages.has(m.pageName))}
onStoreClick={() => {
// Enter a store the user can manage: their own if they own one,
// otherwise the first store they're a staff member of.
const owned = myStores?.find((s) => s.role === "owner");
const target = owned || myStores?.[0];
if (profileData?.sellerUsername) {
navigate(`/store/${profileData.sellerUsername}`);
} else if (target) {
navigate(`/store/${target.username}`);
} else if (user?.access_token) {
setStoreModalOpen(true);
} else {
setLoginModalOpen(true);
}
}}
onLogoutClick={() => {
setLogoutModalOpen(true);
}}
/>
</div>
<StoreModal open={storeModalOpen} onOpenChange={setStoreModalOpen} />
<LogoutModal open={logoutModalOpen} onOpenChange={setLogoutModalOpen} />
<LoginRequiredDialog
loginTitle="برای ورود به فروشگاه من ابتدا باید وارد حساب کاربری خود شوید."
isOpen={loginModalOpen}
onOpenChange={setLoginModalOpen}
/>
</div>
);
}
const MenuItems = ({
items,
onStoreClick,
onLogoutClick,
}: {
items: MenuItemType[];
onStoreClick: () => void;
onLogoutClick: () => void;
}) => {
const { user } = useRootData();
const handleSupportClick = () => {
const supportPhone = import.meta.env.VITE_SUPPORT_PHONE || "";
window.location.href = `tel:${supportPhone}`;
};
return (
<div className="flex flex-col gap-0 w-full lg:grid lg:grid-cols-2 lg:gap-3 lg:mt-4">
{items.map((item: MenuItemType, index: number) => {
// Special handling for "support" item
if (item.pageName === "support") {
return (
<MenuItem key={index} item={item} onClick={handleSupportClick} />
);
}
// Special handling for "store" item
if (item.pageName === "store") {
return <MenuItem key={index} item={item} onClick={onStoreClick} />;
}
// Special handling for "logout" item
if (item.pageName === "logout") {
if (user?.access_token) {
return <MenuItem key={index} item={item} onClick={onLogoutClick} />;
}
return null;
}
// Default rendering for other items with Link
return (
<Link key={index} to={item.link || ""} className="w-full">
<MenuItem.Content item={item} />
</Link>
);
})}
</div>
);
};
// Reusable MenuItem component
const MenuItem = ({
item,
onClick,
}: {
item: MenuItemType;
onClick: () => void;
}) => {
return (
<div
className="w-full"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && onClick()}
>
<MenuItem.Content item={item} />
</div>
);
};
// Content subcomponent to be used with or without clickable wrapper
const MenuItemContent = ({ item }: { item: MenuItemType }) => {
return (
<div
className={`flex flex-row gap-2 items-center justify-between px-[16px] active:bg-hover transition cursor-pointer ${item.hasBorder && "border-t-[2px]"} h-[55px] w-full lg:h-auto lg:py-4 lg:px-5 lg:border lg:border-WHITE3 lg:rounded-xl lg:border-t lg:hover:bg-WHITE2`}
>
<div className="flex flex-row gap-2 items-center">
{item.image}
<p className={"text-R12"}>{item.title}</p>
</div>
<div className="flex items-center gap-2">
{item.badge ? (
<span className="min-w-[18px] h-[18px] px-1 rounded-full bg-RED text-white text-[10px] font-bold grid place-items-center">
{item.badge > 99 ? "99+" : item.badge}
</span>
) : null}
<ChevronLeft className="size-4" />
</div>
</div>
);
};
// Assign the content component as a property of MenuItem
MenuItem.Content = MenuItemContent;
MenuItemContent.displayName = "MenuItem.Content";
const ProfileTopBar = ({ user }: { user: User }) => {
const { data: dataProfile, isFetching: isFetchingProfile } =
useServiceGetProfile();
return (
<div className="flex flex-row h-[65px] w-full items-center gap-2 justify-between px-[16px]">
<div className="sticky top-0 z-30 flex flex-row gap-2 items-center">
<Link to={"/profile/setting"}>
<CircleUserRound size={28} />
</Link>
{isFetchingProfile ? (
<CurvedButton title="" isLoading={true} />
) : user && user.access_token ? (
dataProfile && dataProfile.username ? (
<p className="text-R14">{dataProfile.username}</p>
) : (
<Link to={"/profile/setting"}>
<CurvedButton title="تکمیل اطلاعات کاربری" />
</Link>
)
) : (
<Link to={"/login"}>
<CurvedButton title="ورود / ثبت نام" />
</Link>
)}
</div>
</div>
);
};
export const CurvedButton = ({
title,
isLoading,
}: {
title: string;
isLoading?: boolean;
}) => {
return (
<>
{isLoading ? (
<Skeleton className="w-[100px] h-[30px] bg-VITROWN_BLUE text-white" />
) : (
<div
className={`px-4 py-1 rounded-[100px] bg-VITROWN_BLUE text-white`}
>
<p className="text-B14 font-bold">{title}</p>
</div>
)}
</>
);
};
const LogoutModal = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const navigate = useNavigate();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogOverlay />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-3 items-center w-full">
<LogOut size={24} />
<p className="text-B12 font-bold mt-1">خروج از حساب کاربری</p>
<p className="text-R12">میخواهید از حساب کاربری خود خارج شوید؟</p>
</div>
<div className="flex flex-row gap-6 w-full">
<Button
className="w-[100%]"
size={"sm"}
variant="dark"
onClick={() => onOpenChange(false)}
>
انصراف
</Button>
<Button
className="w-[100%]"
variant="primary"
size={"sm"}
onClick={() => navigate("/logout")}
>
خروج از حساب
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
const StoreModal = ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const navigate = useNavigate();
const handleActionClick = () => {
navigate("/store/create");
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogOverlay />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-3 items-center w-full">
<Store size={24} />
<p className="text-B12 font-bold mt-1">
شما در فروشگاه ثبت نشدهاید
</p>
<p className="text-R12">میخواهید فروشگاه خود را ایجاد کنید؟</p>
</div>
<div className="flex flex-row gap-6 w-full">
<Button
className="w-[100%]"
size={"sm"}
variant="dark"
onClick={() => onOpenChange(false)}
>
انصراف
</Button>
<Button
className="w-[100%]"
variant="primary"
size={"sm"}
onClick={handleActionClick}
>
ایجاد فروشگاه
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export const meta: MetaFunction = () => {
const title = "پروفایل کاربری | ویترون";
const description =
"مدیریت حساب کاربری، مشاهده سفارش‌ها، کیف پول، آدرس‌ها و تنظیمات شخصی در ویترون.";
const imageUrl =
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
return [
{ title },
{ name: "description", content: description },
{
name: "keywords",
content: "پروفایل کاربری، حساب کاربری، سفارش‌ها، کیف پول، آدرس",
},
{ name: "robots", content: "noindex, follow" },
// Open Graph
{ property: "og:type", content: "website" },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: imageUrl },
{ property: "og:site_name", content: "ویترون" },
{ property: "og:locale", content: "fa_IR" },
// Twitter Card
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
};