Vitron-Front/app/routes/profile._index.tsx
Arda Samadi 0f617b45c9 feat(profile): show which area a notification is for (chat / orders)
The bottom-nav profile badge sums unread chat + order updates, but the
profile page gave no breakdown. Wire useBadgeCounts into the mobile
account hub: the پیام‌ها (messages) menu item shows the chat unread count,
and the سفارش‌ها quick action shows the order-updates count. Adds a badge
slot to the menu item content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:18:43 +03:30

567 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 { 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: 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={() => {
if (profileData?.sellerUsername) {
navigate(`/store/${profileData.sellerUsername}`);
} 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 },
];
};