- 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>
573 lines
18 KiB
TypeScript
573 lines
18 KiB
TypeScript
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 },
|
||
];
|
||
};
|