Vitron-Front/app/routes/seller.$sellerId._index.tsx
Arda Samadi 660e01c3c3 feat(seller): customizable profile banner + seller page polish
- Banner editor in store edit: color swatches + custom color picker, or
  image/GIF upload with a 1600x500 dimension hint. Renders the chosen banner
  (image cover / solid color) on the public seller page, falling back to the
  default gradient.
- Sellers list: drop the desktop name overlay (match mobile, image-only tiles).
- Seller page mobile header: chat moved top-left under the banner, search+filter
  beside the follow button, followers/product counts removed (rating kept),
  compacted so products fill more of the screen, circular logo, thinner action row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:00:23 +03:30

614 lines
22 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,
Search,
} 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 AppInput from "~/components/AppInput";
import FilterDrawer from "~/components/FilterDrawer";
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, setSearchParams] = 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]);
// Apply the in-header product search to the URL (drives useGetSellerProducts)
const handleProductSearch = () => {
const params = new URLSearchParams(searchParams);
if (searchValue.trim()) {
params.set("q", searchValue);
} else {
params.delete("q");
}
setSearchParams(params, { replace: true });
};
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);
};
// Customizable banner: image, solid color, or the default gradient fallback.
const bannerIsImage = seller?.bannerType === "image" && !!seller?.bannerImage;
const bannerIsColor = seller?.bannerType === "color" && !!seller?.bannerColor;
const bannerBgClass =
bannerIsImage || bannerIsColor
? ""
: "bg-gradient-to-br from-VITROWN_BLUE to-[#14213d]";
const bannerStyle = bannerIsColor
? { backgroundColor: seller?.bannerColor as string }
: undefined;
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 ${bannerBgClass}`}
style={bannerStyle}
>
{bannerIsImage ? (
<img
src={seller?.bannerImage as string}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
) : null}
<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 (compact: ~chrome small so products fill the screen) */}
<div className="lg:hidden">
{/* Cover */}
<div
className={`relative h-[120px] ${bannerBgClass}`}
style={bannerStyle}
>
{bannerIsImage ? (
<img
src={seller?.bannerImage as string}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
) : null}
<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">
{/* Logo (start/right) + Chat (end/left, just under the banner) */}
<div className="flex items-end justify-between">
<div className="w-[84px] h-[84px] rounded-full 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>
<button
type="button"
onClick={handleCreateChatThread}
disabled={!seller?.id || isCreatingChatThread}
aria-label="ارسال پیام"
className="w-10 h-10 rounded-full bg-WHITE shadow-md border border-WHITE3 flex items-center justify-center text-BLACK active:scale-95 transition-transform disabled:opacity-60"
>
<MessageCircle size={20} />
</button>
</div>
<h1 className="flex items-center gap-1.5 text-[21px] font-extrabold mt-2">
{seller?.storeName}
{seller?.isVerified ? (
<BadgeCheck className="text-BLUE" size={18} />
) : null}
</h1>
{/* Handle + rating inline (followers/product count removed) */}
<div className="flex items-center gap-2 mt-0.5">
{seller?.username ? (
<p className="text-GRAY text-[13px]" dir="ltr">
@{seller.username}
</p>
) : null}
{seller?.productReviewsCount ? (
<span className="flex items-center gap-1 text-[13px] font-bold">
<Star size={13} className="fill-BLACK" />
{seller?.reviewAverage}
</span>
) : null}
</div>
{/* Description */}
{seller?.storeDescription ? (
<div className="mt-2">
<ReadMoreText
sellerData={seller}
text={seller?.storeDescription || ""}
maxLength={110}
/>
</div>
) : null}
{/* Actions: follow + search + filter together */}
<div className="flex items-center gap-2 mt-3">
<Button
variant="dark"
size="lg"
className="shrink-0 !h-10"
onClick={handleFollowToggle}
disabled={isFollowMutating || isFollowStatusLoading}
>
{isFollowed ? (
<>
<Check size={20} />
دنبال شده
</>
) : (
<>
<UserPlus size={20} />
دنبال کردن
</>
)}
</Button>
<div className="flex-1 flex items-center gap-2">
<AppInput
inputIcon={Search}
inputDir="rtl"
inputTitle={""}
onKeyDown={(e) => {
if (e.key === "Enter") handleProductSearch();
}}
className="bg-WHITE2 focus:border-blue-500 !h-10"
inputValue={searchValue}
inputPlaceholder="جستجو در محصولات..."
inputOnChange={(e) => setSearchValue(e.target.value)}
/>
<FilterDrawer minimumPrice={0} maximumPrice={10000000} />
</div>
</div>
</div>
</div>
<div className="w-full mt-3 lg:mt-0">
<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 },
];
};