Vitron-Front/app/routes/seller.$sellerId._index.tsx
Arda Samadi 485f8d460a Security fixes: session hardening, open redirect, SSR path injection
- sessions: require REMIX_SECRET in prod (no "secret" fallback); base Secure
  cookie on NODE_ENV; add maxAge
- login: safeRedirect() blocks open-redirect via redirectTo (loader + action)
- SSR loaders: encodeURIComponent on user path params before internal API
  fetch (product, store product, seller)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:02:11 +03:30

465 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { useLoaderData } from "@remix-run/react";
import { safeGoBack } from "~/utils/helpers";
import { MetaFunction, LoaderFunctionArgs, json } from "@remix-run/node";
import { getInternalApiUrl } from "~/utils/api-config";
import {
useSellerFollowStatus,
useSellerFollow,
useSellerUnfollow,
useGetSellerProducts,
} from "../requestHandler/use-seller-hooks";
import { useCreateChatThread } from "../requestHandler/use-chat-hooks";
import {
Star,
ChevronDown,
X,
UserPlus,
Check,
UserMinus,
Shirt,
} from "lucide-react";
import HeaderWithSupport from "../components/HeaderWithSupport";
import { useState, useEffect } from "react";
import { DrawerContent, Drawer } from "../components/ui/drawer";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogOverlay } from "../components/ui/dialog";
import { toast } from "../hooks/use-toast";
import { Seller as SellerType } from "../../src/api/types";
import MondrianProductList from "../components/MondrianProductList";
import UiProvider from "../components/UiProvider";
import { useRootData } from "~/hooks/use-root-data";
import { LoginRequiredDialog } from "~/components/LoginRequiredDialog";
import SellerLogo from "../components/SellerLogo";
import SearchTopBar from "~/components/SearchTopBar";
interface LoaderData {
seller: SellerType;
}
// Map order param to API ordering format
const mapOrderToOrdering = (order: string): string | undefined => {
switch (order) {
case "cheap":
return "base_price";
case "expensive":
return "-base_price";
case "newest":
return "-created_at";
default:
return undefined;
}
};
// Server-side loader to fetch seller data
export async function loader({ params }: LoaderFunctionArgs) {
const { sellerId } = params;
if (!sellerId) {
throw new Response("Seller ID is required", { status: 400 });
}
try {
const response = await fetch(`${getInternalApiUrl()}/seller/${encodeURIComponent(sellerId)}/`);
if (!response.ok) {
throw new Error(`Failed to fetch seller: ${response.status}`);
}
const seller = await response.json();
return json<LoaderData>({ seller });
} catch (error) {
console.error("Error fetching seller:", error);
throw new Response("Error loading seller", { status: 500 });
}
}
//
export default function Seller() {
const { sellerId } = useParams();
const { seller } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const { data: isFollowed, isLoading: isFollowStatusLoading } =
useSellerFollowStatus(seller?.id || "");
const { mutate: followSeller, isPending: isFollowPending } =
useSellerFollow();
const { mutate: unfollowSeller, isPending: isUnfollowPending } =
useSellerUnfollow();
const navigate = useNavigate();
const [showUnfollowDialog, setShowUnfollowDialog] = useState(false);
const { mutate: createChatThread, isPending: isCreatingChatThread } =
useCreateChatThread();
const { user } = useRootData();
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false);
const [searchValue, setSearchValue] = useState(searchParams.get("q") || "");
// Get filter parameters from URL
const orderParam = searchParams.get("order");
const priceMinParam = searchParams.get("priceMin");
const priceMaxParam = searchParams.get("priceMax");
// Build filter options
const filterOptions = {
q: searchParams.get("q") || undefined,
priceMin: priceMinParam ? parseInt(priceMinParam, 10) : undefined,
priceMax: priceMaxParam ? parseInt(priceMaxParam, 10) : undefined,
ordering: orderParam ? mapOrderToOrdering(orderParam) : undefined,
};
// Fetch seller products with infinite scroll and filters
const {
data: productsData,
isLoading: isProductsLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
refetch,
} = useGetSellerProducts(sellerId || "", filterOptions);
const products = productsData?.pages.flatMap((page) => page.results) || [];
// Update search value when URL changes
useEffect(() => {
setSearchValue(searchParams.get("q") || "");
}, [searchParams]);
const handleFollowToggle = () => {
if (!user) {
setIsLoginDialogOpen(true);
return;
}
if (!sellerId) return;
if (isFollowed) {
// If already following, show confirmation dialog
setShowUnfollowDialog(true);
} else {
// If not following, follow
followSeller(
{ sellerId: seller?.id || "" },
{
onSuccess: () => {
// Close the dialog only after successful unfollow
toast({
title: "دنبال شد",
description: "شما با موفقیت فروشگاه را دنبال کردید",
});
setShowUnfollowDialog(false);
},
},
);
}
};
const confirmUnfollow = () => {
// Don't close the dialog immediately, wait for the request to succeed
unfollowSeller(
{ sellerId: seller?.id || "" },
{
onSuccess: () => {
// Close the dialog only after successful unfollow
toast({
title: "آنفالو شد",
description: "شما با موفقیت دنبال کردن فروشگاه را لغو کردید",
});
setShowUnfollowDialog(false);
},
},
);
};
const handleCreateChatThread = () => {
if (!user) {
setIsLoginDialogOpen(true);
return;
}
if (!seller?.id) return;
createChatThread(seller.id, {
onSuccess: (threadData) => {
navigate(`/profile/threads/${threadData.id}`);
},
});
};
const isFollowMutating = isFollowPending || isUnfollowPending;
const handleBack = () => {
safeGoBack(navigate);
};
return (
<UiProvider
isLoading={false}
isError={isError}
isEmpty={false}
onRetry={refetch}
type="seller"
>
<div className="flex flex-col pb-20">
{/* Header */}
<div className="relative">
<HeaderWithSupport
title={seller?.storeName || ""}
onBack={handleBack}
/>
<div className="flex flex-col items-center justify-center gap-2 pt-2">
<SellerLogo src={seller?.storeLogo} alt="store logo" />
</div>
</div>
<div className="w-full">
<div className="w-full p-4">
<div className="flex px-4 border bg-WHITE3 rounded-[10px] w-full py-2">
<div className="w-full border-l flex-2 flex flex-col items-center justify-center">
<p className="text-R12">{seller?.followerCount}</p>
<p className="text-R12">دنبال کننده</p>
</div>
<div className="w-full flex-3 flex flex-col items-center justify-center">
{seller?.productReviewsCount ? (
<>
<div className="flex items-center gap-1">
<p className="text-R12">{seller?.reviewAverage}</p>
<p className="text-R8">
(از {seller?.productReviewsCount} نظر)
</p>
</div>
<div className="flex flex-1 items-center flex-row-reverse">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={
index < (seller?.reviewAverage || 0)
? "fill-BLACK"
: "fill-White"
}
size={12}
/>
))}
</div>
</>
) : (
<p className="text-R12">بدون امتیاز</p>
)}
</div>
<div className="w-full border-r flex-2 flex flex-col items-center justify-center">
<p className="text-R12">{seller?.productCount}</p>
<p className="text-R12">تعداد محصولات</p>
</div>
</div>
</div>
<div className="mt-6 px-4">
<ReadMoreText
sellerData={seller}
text={seller?.storeDescription || ""}
maxLength={150}
/>
</div>
<div className="mt-4 px-4 w-full flex py-4 gap-4">
<Button
variant="dark"
size="lg"
className="w-full"
onClick={handleFollowToggle}
disabled={isFollowMutating || isFollowStatusLoading}
>
{isFollowed ? (
<>
<Check size={24} />
دنبال شده
</>
) : (
<>
<UserPlus size={24} />
دنبال کردن
</>
)}
</Button>
<Button
variant="primary"
onClick={() => {
handleCreateChatThread();
}}
disabled={!seller?.id || isCreatingChatThread}
size="lg"
className="w-full"
>
{isCreatingChatThread ? "در حال اتصال..." : "ارسال پیام"}
</Button>
</div>
{/* Search Bar */}
<SearchTopBar
searchValue={searchValue}
setSearchValue={setSearchValue}
placeholder="جستجو در محصولات فروشگاه..."
minimumPrice={0}
maximumPrice={10000000}
/>
{/* seller products */}
<MondrianProductList
products={products.map((product) => ({
id: product?.id || "",
title: product?.title || "",
images: product?.imageUrls || [],
basePrice: product?.basePrice
? parseInt(product?.basePrice)
: undefined,
discountPercent: product?.discountPercent
? product?.discountPercent
: undefined,
discountPrice: product?.discountPrice
? product?.discountPrice
: undefined,
}))}
fetchNextPage={fetchNextPage}
hasNextPage={!!hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isProductsLoading}
isError={isError}
refetch={refetch}
emptyState={{
image: <Shirt size={80} />,
title: "این فروشگاه هیچ محصولی ندارد",
description: "به زودی محصولات جدیدی از این فروشگاه خواهیم داشت",
}}
/>
</div>
<Dialog open={showUnfollowDialog} onOpenChange={setShowUnfollowDialog}>
<DialogOverlay className="bg-BLACK/20" />
<DialogContent className="p-4 bg-WHITE overflow-y-auto max-h-[80vh] rounded-[15px] w-[calc(100%-40px)]">
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-3 items-center w-full">
<UserMinus 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={() => setShowUnfollowDialog(false)}
>
انصراف
</Button>
<Button
disabled={isUnfollowPending}
className="w-[100%]"
size={"sm"}
variant="primary"
onClick={() => confirmUnfollow()}
>
{isUnfollowPending ? (
<>
<div className="relative w-5 h-5 mr-2">
<div className="w-full h-full border-2 border-t-RED border-r-transparent border-b-transparent border-l-transparent rounded-full animate-spin"></div>
<div
className=" w-4 h-4 border-2 border-t-transparent border-r-RED border-b-transparent border-l-transparent rounded-full animate-spin"
style={{
animationDirection: "reverse",
animationDuration: "0.7s",
}}
></div>
</div>
درحال لغو...
</>
) : (
"لغو دنبال کردن"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<LoginRequiredDialog
loginTitle="برای دنبال کردن فروشگاه ابتدا باید وارد حساب کاربری خود شوید."
isOpen={isLoginDialogOpen}
onOpenChange={setIsLoginDialogOpen}
/>
</div>
</UiProvider>
);
}
function ReadMoreText({
text,
maxLength,
sellerData,
}: {
text: string;
maxLength: number;
sellerData: SellerType | undefined;
}) {
const [aboutDrawerOpen, setAboutDrawerOpen] = useState(false);
if (text.length <= maxLength) {
return <p className="text-R12">{text}</p>;
}
return (
<>
<div className="relative">
<p className="text-R12">
{text.substring(0, maxLength)}
<span>...</span>
<button
onClick={() => setAboutDrawerOpen(true)}
className="inline-flex items-center text-blue-500 mr-1 focus:outline-none"
>
<span>بیشتر</span>
<ChevronDown size={16} />
</button>
</p>
</div>
<Drawer open={aboutDrawerOpen} onOpenChange={setAboutDrawerOpen}>
<DrawerContent className="flex flex-col gap-4">
<div className="flex relative items-center justify-center w-full">
<X
className="cursor-pointer"
size={24}
onClick={() => setAboutDrawerOpen(false)}
/>
<div className="flex flex-col gap-2 py-4 items-center">
<SellerLogo src={sellerData?.storeLogo} alt="Seller Logo" />
<p className="text-B14 font-bold">{sellerData?.storeName}</p>
</div>
</div>
<p className="text-R12 px-4 pb-10">{text}</p>
</DrawerContent>
</Drawer>
</>
);
}
export const meta: MetaFunction<typeof loader> = ({ params, data }) => {
const sellerId = params.sellerId;
const sellerName = data?.seller?.storeName;
const title = `فروشگاه ${sellerName} | ویترون`;
const description = `مشاهده محصولات و اطلاعات فروشگاه ${sellerName} در ویترون`;
const imageUrl =
data?.seller?.storeLogo ||
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
const siteUrl = `${import.meta.env.VITE_SITE_URL || "https://vitrown.com"}/seller/${sellerId}`;
return [
{ title },
{ name: "description", content: description },
{
name: "keywords",
content: `فروشگاه، ${sellerName}، محصولات، ویترون`,
},
{ name: "robots", content: "index, 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" },
{ property: "og:url", content: siteUrl },
// Twitter Card
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
{ name: "twitter:image", content: imageUrl },
];
};