- 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>
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
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 },
|
||
];
|
||
};
|