Vitron-Front/app/routes/seller.$sellerId._index.tsx
Arda Samadi cf55e16a9a Desktop: seller brand header redesign + collections list/detail
- seller: desktop brand header matching the design — cover, overlapping rounded
  logo, name + verified badge + @handle, horizontal stats, Follow/Message
  actions, description; mobile blocks hidden at lg; feed = responsive masonry
- /collections: 4-column card grid on desktop (was a stretched single column)
- /collection/<id>: two-column hero (cover + info) on desktop, contained width,
  mobile header hidden at lg; products use the responsive masonry
- MyLayout: desktop gate now includes /collections and /collection/*

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:00:49 +03:30

559 lines
20 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 { 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,
BadgeCheck,
} 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 lg:max-w-[1100px] lg:mx-auto lg:w-full lg:px-8 lg:pt-8">
{/* Desktop brand header (cover + overlapping logo + stats + actions) */}
<div className="hidden lg:block">
<div className="relative h-[200px] rounded-[22px] overflow-hidden bg-gradient-to-br from-VITROWN_BLUE to-[#14213d]">
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
</div>
<div className="flex items-end gap-6 px-2 -mt-16 relative z-10">
<div className="w-[130px] h-[130px] rounded-[28px] border-[5px] border-WHITE shadow-md overflow-hidden bg-WHITE3 shrink-0">
{seller?.storeLogo ? (
<img
src={seller.storeLogo}
alt={seller?.storeName || "store"}
className="w-full h-full object-cover"
/>
) : null}
</div>
<div className="flex-1 pb-2">
<h1 className="flex items-center gap-2 text-[28px] font-extrabold">
{seller?.storeName}
{seller?.isVerified ? (
<BadgeCheck className="text-BLUE" size={22} />
) : null}
</h1>
{seller?.username ? (
<p className="text-GRAY text-sm mt-0.5 mb-3" dir="ltr">
@{seller.username}
</p>
) : (
<div className="mb-3" />
)}
<div className="flex gap-8">
<div>
<b className="block text-[20px] font-extrabold">
{seller?.productCount ?? 0}
</b>
<span className="text-GRAY text-[13px]">محصول</span>
</div>
<div>
<b className="block text-[20px] font-extrabold">
{seller?.followerCount ?? 0}
</b>
<span className="text-GRAY text-[13px]">دنبالکننده</span>
</div>
<div>
<b className="flex items-center gap-1 text-[20px] font-extrabold">
{seller?.productReviewsCount ? seller?.reviewAverage : "—"}
{seller?.productReviewsCount ? (
<Star size={16} className="fill-BLACK" />
) : null}
</b>
<span className="text-GRAY text-[13px]">امتیاز فروشگاه</span>
</div>
</div>
</div>
<div className="flex gap-2.5 pb-2">
<Button
variant="dark"
size="lg"
onClick={handleFollowToggle}
disabled={isFollowMutating || isFollowStatusLoading}
>
{isFollowed ? (
<>
<Check size={20} />
دنبال شده
</>
) : (
<>
<UserPlus size={20} />
دنبال کردن
</>
)}
</Button>
<Button
variant="outline"
size="lg"
onClick={handleCreateChatThread}
disabled={!seller?.id || isCreatingChatThread}
>
{isCreatingChatThread ? "در حال اتصال..." : "ارسال پیام"}
</Button>
</div>
</div>
{seller?.storeDescription ? (
<p className="text-BLACK2 text-[14.5px] leading-8 max-w-[760px] mt-5 px-2">
{seller.storeDescription}
</p>
) : null}
<div className="border-b border-WHITE3 mt-6" />
</div>
{/* Mobile header */}
<div className="relative lg:hidden">
<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 lg:hidden">
<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 lg:hidden">
<ReadMoreText
sellerData={seller}
text={seller?.storeDescription || ""}
maxLength={150}
/>
</div>
<div className="mt-4 px-4 w-full flex py-4 gap-4 lg:hidden">
<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 (mobile) */}
<div className="lg:hidden">
<SearchTopBar
searchValue={searchValue}
setSearchValue={setSearchValue}
placeholder="جستجو در محصولات فروشگاه..."
minimumPrice={0}
maximumPrice={10000000}
/>
</div>
<div className="hidden lg:block lg:mt-2" />
{/* 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 },
];
};