Vitron-Front/app/routes/seller.$sellerId._index.tsx
Arda Samadi e6574b1e9e feat(seller): mobile brand header redesign (cover + overlapping logo)
Bring the seller page mobile header in line with the design / desktop:
replace the plain top bar + centered logo with a cover banner, a floating
circular back button (safe-area aware), an overlapping rounded logo, the
store name + verified badge + @handle, a clean borderless stats row
(محصول / دنبال‌کننده / امتیاز), the description, and follow + message-icon
actions. Search bar and product masonry unchanged. Desktop untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:44:20 +03:30

579 lines
21 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,
ArrowRight,
MessageCircle,
} from "lucide-react";
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-start gap-6 px-2 mt-4 relative z-10">
<div className="w-[130px] h-[130px] -mt-[86px] 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">
<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">
<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 brand header (cover + overlapping logo + stats + actions) */}
<div className="lg:hidden">
{/* Cover */}
<div className="relative h-[150px] bg-gradient-to-br from-VITROWN_BLUE to-[#14213d]">
<div className="absolute inset-0 bg-gradient-to-t from-black/35 to-transparent" />
<button
type="button"
onClick={handleBack}
aria-label="بازگشت"
className="absolute z-20 right-4 w-10 h-10 rounded-full bg-WHITE/90 backdrop-blur shadow-md flex items-center justify-center text-BLACK"
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
>
<ArrowRight size={22} />
</button>
</div>
{/* Head */}
<div className="px-4 -mt-10 relative z-10">
<div className="w-[84px] h-[84px] rounded-[24px] border-4 border-WHITE shadow-md overflow-hidden bg-WHITE3">
{seller?.storeLogo ? (
<img
src={seller.storeLogo}
alt={seller?.storeName || "store"}
className="w-full h-full object-cover"
/>
) : null}
</div>
<h1 className="flex items-center gap-1.5 text-[21px] font-extrabold mt-3">
{seller?.storeName}
{seller?.isVerified ? (
<BadgeCheck className="text-BLUE" size={18} />
) : null}
</h1>
{seller?.username ? (
<p className="text-GRAY text-[13px] mt-0.5" dir="ltr">
@{seller.username}
</p>
) : null}
{/* Stats */}
<div className="flex gap-8 py-3.5 mt-3 border-y border-WHITE3">
<div>
<b className="block text-[17px] font-extrabold">
{seller?.productCount ?? 0}
</b>
<span className="text-GRAY text-[11.5px]">محصول</span>
</div>
<div>
<b className="block text-[17px] font-extrabold">
{seller?.followerCount ?? 0}
</b>
<span className="text-GRAY text-[11.5px]">دنبالکننده</span>
</div>
<div>
<b className="flex items-center gap-1 text-[17px] font-extrabold">
{seller?.productReviewsCount ? seller?.reviewAverage : "—"}
{seller?.productReviewsCount ? (
<Star size={14} className="fill-BLACK" />
) : null}
</b>
<span className="text-GRAY text-[11.5px]">امتیاز</span>
</div>
</div>
{/* Description */}
{seller?.storeDescription ? (
<div className="mt-3">
<ReadMoreText
sellerData={seller}
text={seller?.storeDescription || ""}
maxLength={150}
/>
</div>
) : null}
{/* Actions */}
<div className="flex gap-2.5 mt-4">
<Button
variant="dark"
size="lg"
className="flex-1"
onClick={handleFollowToggle}
disabled={isFollowMutating || isFollowStatusLoading}
>
{isFollowed ? (
<>
<Check size={22} />
دنبال شده
</>
) : (
<>
<UserPlus size={22} />
دنبال کردن
</>
)}
</Button>
<Button
variant="outline"
size="lg"
className="shrink-0 px-4"
aria-label="ارسال پیام"
onClick={() => {
handleCreateChatThread();
}}
disabled={!seller?.id || isCreatingChatThread}
>
<MessageCircle size={22} />
</Button>
</div>
</div>
</div>
<div className="w-full">
{/* 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 },
];
};