Vitron-Front/app/routes/profile._index.tsx
Arda Samadi 207d46eefd Profile hard-guard: redirect /profile to /login when logged out
The account sub-pages were already protected via validateTokens; add a loader
on the profile index so the hub itself bounces to /login when unauthenticated.
2026-06-16 15:00:53 +03:30

454 lines
14 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,
} from "lucide-react";
import { Link, useNavigate } from "@remix-run/react";
import { useServiceGetProfile } from "~/utils/RequestHandler";
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;
}
// 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: followedSellers, isLoading: isFollowedSellersLoading } =
useServiceGetFollowedSellers();
// Calculate the count of followed sellers
const followedCount = followedSellers?.length || 0;
// 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} />,
},
{
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 menu */}
<div className="flex flex-col gap-0 lg:hidden">
<ProfileTopBar user={user} />
<div
className={`flex flex-row h-[55px] w-full items-center gap-2 justify-between px-[16px] active:bg-hover transition`}
>
<div className="flex flex-row gap-2 items-center">
<p className={"text-R12"}>
{initialTheme === "dark" ? "حالت تاریک" : "حالت روشن"}
</p>
</div>
<SunMoonToggle theme={initialTheme} />
</div>
<MenuItems
items={menuItems}
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>
<ChevronLeft className="size-4" />
</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 },
];
};