Vitron-Front/app/components/product/ProductDetailView.tsx
fazli 56fcce35c8 feat(product-detail): ProductDescription section + strict discount guard + category types
- app/components/product/ProductDescription.tsx: new below-fold section
  with a "توضیحات محصول" heading, whitespace-pre-line so multi-line seller
  copy (Instagram scrapes, victorwear WooCommerce imports) renders with
  its bullet-list line breaks preserved. Renders on both mobile and
  desktop; nothing shown when the product has no description.
- app/components/product/ProductInfo.tsx: removed the collapsed
  single-<p> mobile description; the new section replaces it.
- app/components/product/ProductDetailView.tsx: pricingOf now hides the
  discount when discount_price >= price (was only hiding when equal),
  so a stale row where the discount price accidentally matches or
  exceeds the base can never render as a fake promo. Mounts
  ProductDescription just above the mobile Contact button and reviews.
- app/components/product/index.ts: export ProductDescription.
- src/api/types/models/index.ts: ProductCategory now carries readonly
  parentId / parentName so the two-step category picker can build the
  parent→sub tree from a flat category list without an extra fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 13:19:27 +00:00

793 lines
29 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 { useState, useEffect, useMemo, memo } from "react";
import {
ProductImageCarousel,
ProductActions,
ProductInfo,
ProductDescription,
ColorSelector,
SizeSelector,
ContactButton,
ReviewSection,
BottomBar,
} from "./index";
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
import {
ArrowRight,
Eye,
EyeOff,
Heart,
Sparkles,
Star,
ShoppingCart,
Truck,
RotateCcw,
ShieldCheck,
MessageCircle,
} from "lucide-react";
import { Button } from "../ui/button";
import { useNavigate, Link } from "@remix-run/react";
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
import MondrianProductList from "../MondrianProductList";
import SellerLogo from "../SellerLogo";
import {
useServiceGetProductRating,
useServiceGetComments,
useServiceCheckBookmark,
useServiceBookmarkProduct,
useServiceDeleteBookmark,
} from "~/utils/RequestHandler";
import { useAddToCart } from "~/requestHandler/use-cart-hooks";
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
import { useRecordProductView } from "~/requestHandler/use-view-hooks";
import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers";
import { useToast } from "~/hooks/use-toast";
import { useRootData } from "~/hooks/use-root-data";
import { LoginRequiredDialog } from "../LoginRequiredDialog";
import AIPromoteModal from "../AIPromoteModal";
import { SizeGuideDialog } from "./SizeGuideDialog";
interface ProductDetailViewProps {
product: ProductDetailType;
isOwner: boolean;
}
export const ProductDetailView = memo(function ProductDetailView({
product,
isOwner,
}: ProductDetailViewProps) {
// Local state for product active status (to handle real-time updates)
const [productActiveStatus, setProductActiveStatus] = useState(
product.isActive || false
);
const [isChangeStatusDialogOpen, setIsChangeStatusDialogOpen] =
useState(false);
// Fetch similar products with infinite scroll
const {
data: similarProductsData,
isLoading: isLoadingSimilar,
isError: isErrorSimilar,
refetch: refetchSimilar,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSimilarProductsInfinite(product.id || "", 20);
// Sync local state when product prop changes (e.g., on navigation)
useEffect(() => {
setProductActiveStatus(product.isActive || false);
}, [product.isActive]);
// Record a product view for the recommendation system (skip the owner's own
// product; only tracks authenticated users, deduped per product).
useRecordProductView(product.id, { enabled: !isOwner });
// Memoize available colors and sizes
const { availableColors, availableSizes } = useMemo(
() => ({
availableColors:
product.attributes?.COLOR?.filter((p) => p.value !== "UNSELECTED") ||
[],
availableSizes:
product.attributes?.SIZE?.filter((p) => p.value !== "UNSELECTED") || [],
}),
[product.attributes]
);
// Get variants with stock
const stockedVariants = useMemo(
() =>
product.variants?.filter(
(variant) => variant.stock && variant.stock > 0
) || [],
[product.variants]
);
// Memoize in-stock colors and sizes
const { inStockColors, inStockSizes } = useMemo(() => {
const colors = availableColors.filter((color) => {
return stockedVariants.some(
(variant) => variant.resolvedAttributes?.COLOR === color.value
);
});
const sizes = availableSizes.filter((size) => {
return stockedVariants.some(
(variant) => variant.resolvedAttributes?.SIZE === size.value
);
});
return { inStockColors: colors, inStockSizes: sizes };
}, [availableColors, availableSizes, stockedVariants]);
// Memoize stock checks
const { hasAnyStock, hasColorVariants, hasSizeVariants } = useMemo(
() => ({
hasAnyStock: stockedVariants.length > 0,
hasColorVariants: inStockColors.length > 0,
hasSizeVariants: inStockSizes.length > 0,
}),
[stockedVariants.length, inStockColors.length, inStockSizes.length]
);
// Effective pricing for a variant: original price + sale price (or null when
// there's no real discount). Mirrors how the list/tile serializer computes
// discount_price so the detail page stays consistent with the tiles.
const pricingOf = (v?: (typeof stockedVariants)[number] | null) => ({
price: v?.price || product.basePrice,
discountPrice:
v?.discountPrice &&
v.discountPrice !== "0" &&
// Only surface as a "discount" when it's a real saving. A value equal
// to (or somehow larger than) `price` is stale/mis-entered state, not a
// discount — sellers occasionally end up with matching numbers when
// they set "no discount" on edit, and rendering a strikethrough on that
// fakes a promo the seller didn't intend.
Number(v.discountPrice) < Number(v.price)
? v.discountPrice
: null,
});
// Cheapest in-stock variant by final (post-discount) price — this is the
// "starting from" price the product tile shows. Used as the default
// selection and as the fallback when no exact color/size combo is matched.
const defaultVariant = useMemo(() => {
const finalOf = (v: (typeof stockedVariants)[number]) => {
const pr = pricingOf(v);
return Number(pr.discountPrice ?? pr.price ?? 0);
};
const pool =
stockedVariants.length > 0 ? stockedVariants : product.variants ?? [];
if (pool.length === 0) return null;
return pool.reduce((best, v) => (finalOf(v) < finalOf(best) ? v : best));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stockedVariants, product.variants]);
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
if (!hasColorVariants || inStockColors.length === 0) return null;
const colorVal = defaultVariant?.resolvedAttributes?.COLOR;
const match =
inStockColors.find((c) => c.value === colorVal) ?? inStockColors[0];
return (match.info as unknown as { detail: string })?.detail ?? null;
});
const [selectedSize, setSelectedSize] = useState<string | null>(() => {
if (!hasSizeVariants || inStockSizes.length === 0) return null;
return defaultVariant?.resolvedAttributes?.SIZE ?? inStockSizes[0].value;
});
// Handle product status change
const handleProductStatusChange = (newStatus: boolean) => {
setProductActiveStatus(newStatus);
};
const [currentPrice, setCurrentPrice] = useState(
() => pricingOf(defaultVariant).price
);
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
string | null
>(() => pricingOf(defaultVariant).discountPrice);
const [currentStock, setCurrentStock] = useState<number | null>(null);
useEffect(() => {
const matchingVariant = product.variants?.find((variant) => {
let colorMatch = true;
let sizeMatch = true;
// Check color match only if product has color variants
if (hasColorVariants) {
if (selectedColor) {
// Find the color value that matches the selected color detail
const selectedColorValue = inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail ===
selectedColor
)?.value;
colorMatch = variant.resolvedAttributes?.COLOR === selectedColorValue;
} else {
colorMatch = false; // No color selected but product has color variants
}
}
// Check size match only if product has size variants
if (hasSizeVariants) {
if (selectedSize) {
sizeMatch = variant.resolvedAttributes?.SIZE === selectedSize;
} else {
sizeMatch = false; // No size selected but product has size variants
}
}
return colorMatch && sizeMatch;
});
if (matchingVariant) {
const pr = pricingOf(matchingVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(matchingVariant.stock || null);
} else if (
!hasColorVariants &&
!hasSizeVariants &&
stockedVariants.length > 0
) {
// No color/size dimensions → use the first available variant.
const firstVariant = stockedVariants[0];
const pr = pricingOf(firstVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(firstVariant.stock || null);
} else {
// Color/size not fully matched yet: show the cheapest variant's price +
// sale (same as the product tile) instead of basePrice with no discount.
const pr = pricingOf(defaultVariant);
setCurrentPrice(pr.price);
setCurrentDiscountPrice(pr.discountPrice);
setCurrentStock(null);
}
}, [
selectedColor,
selectedSize,
product,
inStockColors,
hasColorVariants,
hasSizeVariants,
stockedVariants,
defaultVariant,
]);
const navigate = useNavigate();
// --- Desktop buy/actions state & data (overlays + info column) ---
const { user } = useRootData();
const { toast } = useToast();
const [qty, setQty] = useState(1);
const [isAIOpen, setIsAIOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const [isSizeGuideOpen, setIsSizeGuideOpen] = useState(false);
const hasSizeChart = !!(
product.sizeChart &&
product.sizeChart.columns?.length &&
product.sizeChart.rows?.length
);
const { data: ratingData } = useServiceGetProductRating(product.id || "");
const { data: commentsData } = useServiceGetComments(1, product.id || "");
const { data: bookmarkData } = useServiceCheckBookmark(product.id || "");
const { mutate: bookmarkProduct } = useServiceBookmarkProduct();
const { mutate: deleteBookmark } = useServiceDeleteBookmark();
const addToCartMutation = useAddToCart();
const { mutate: createChatThread } = useCreateChatThread();
const isBookmarked = bookmarkData?.bookmarked === true;
const avgRating = ratingData?.averageRating || 0;
const reviewCount = commentsData?.count || 0;
const isOutOfStock = currentStock === null || currentStock <= 0;
const selectedColorValue =
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail === selectedColor
)?.value || null;
const handleToggleBookmark = () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (isBookmarked) deleteBookmark(product.id || "");
else bookmarkProduct(product.id || "");
};
const handleDesktopAddToCart = async () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (isOutOfStock) return;
const variantId = findVariantId(product, selectedColorValue, selectedSize);
if (!variantId) {
toast({
title: "خطا",
description: "محصول با ویژگی‌های انتخابی شما یافت نشد",
variant: "destructive",
});
return;
}
try {
await addToCartMutation.mutateAsync({ variantId, quantity: qty });
toast({
title: "سبد خرید",
description: "محصول با موفقیت به سبد خرید اضافه شد",
});
} catch (e) {
toast({
title: "خطا",
description: "افزودن به سبد خرید با مشکل مواجه شد",
variant: "destructive",
});
}
};
const handleChat = () => {
if (!user?.access_token) {
setIsLoginOpen(true);
return;
}
if (!product.seller?.id) return;
createChatThread(
{ participantId: product.seller.id, productId: product.id },
{
onSuccess: (t) => navigate(`/profile/threads/${t.id}`),
}
);
};
return (
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
{/* Desktop breadcrumb */}
<nav className="hidden lg:flex items-center gap-2 text-[13px] text-GRAY mt-4 mb-4">
<Link to="/" className="hover:text-BLACK">
خانه
</Link>
<span>/</span>
{product.seller?.username ? (
<>
<Link
to={`/seller/${product.seller.username}`}
className="hover:text-BLACK"
>
{product.seller.username}
</Link>
<span>/</span>
</>
) : null}
<span className="text-BLACK truncate max-w-[320px]">
{product.title}
</span>
</nav>
{/* Top section: gallery | info (two columns on desktop) */}
<div className="lg:grid lg:grid-cols-2 lg:gap-10 lg:items-start">
{/* Gallery */}
<div className="relative lg:rounded-[14px] lg:overflow-hidden lg:shadow-sm lg:sticky lg:top-[88px] lg:aspect-[3/4] lg:bg-WHITE2 lg:[&_img]:!object-contain lg:[&_video]:!object-contain">
<ProductImageCarousel
images={product.imageUrls || []}
productId={product.id}
storeId={product.seller?.username}
isOwner={isOwner}
borderRadius={0}
isActive={productActiveStatus}
controlsIsOn={true}
showArrows={true}
onStatusChange={handleProductStatusChange}
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
/>
{!isOwner && (
<>
{/* Save (desktop overlay) */}
<button
type="button"
onClick={handleToggleBookmark}
aria-label="ذخیره"
className="hidden lg:flex absolute top-3.5 left-3.5 z-20 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center hover:bg-WHITE transition-colors"
>
<Heart
size={20}
className={isBookmarked ? "fill-RED text-RED" : "text-BLACK"}
/>
</button>
{/* AI try-on (desktop overlay) */}
<button
type="button"
onClick={() => setIsAIOpen(true)}
className="hidden lg:flex absolute bottom-4 left-1/2 -translate-x-1/2 z-20 items-center gap-2 px-5 h-11 rounded-full bg-BLACK/85 backdrop-blur text-white text-sm font-bold hover:bg-BLACK transition-colors"
>
<Sparkles size={18} />
آنلاین پرو کن
</button>
</>
)}
{/* Floating back button (mobile) */}
{!isOwner && (
<button
type="button"
onClick={() => safeGoBack(navigate)}
aria-label="بازگشت"
className="lg:hidden 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>
{/* Info */}
<div className="flex flex-col">
{/* Brand row (mobile) — moved out of the top bar, sits below image */}
{!isOwner && product.seller?.username ? (
<div className="lg:hidden flex items-center gap-3 px-4 mt-4">
<Link
to={`/seller/${product.seller.username}`}
className="flex items-center gap-2.5 min-w-0"
>
<SellerLogo
size="sm"
src={product.seller?.storeLogo}
alt="seller"
/>
<span className="font-bold text-[14px] truncate">
{product.seller.username}
</span>
</Link>
<button
type="button"
onClick={handleChat}
className="ms-auto shrink-0 h-9 px-4 rounded-full border border-BLACK text-BLACK text-[12px] font-bold flex items-center gap-1.5"
>
<MessageCircle size={16} />
ارسال پیام
</button>
</div>
) : null}
{/* Seller (desktop) */}
{!isOwner && product.seller?.username ? (
<Link
to={`/seller/${product.seller.username}`}
className="hidden lg:flex items-center gap-2.5 mb-1"
>
<SellerLogo
size="sm"
src={product.seller?.storeLogo}
alt="seller"
/>
<span className="font-bold text-[15px]">
{product.seller.username}
</span>
</Link>
) : null}
{/* Inactive Product Status */}
{!productActiveStatus && isOwner ? (
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2 lg:px-0">
<div className="flex items-center w-full gap-2 p-2 bg-WHITE3 rounded-lg">
<EyeOff size={16} className="text-BLACK2 font-bold" />
<span className="text-B12 font-bold text-BLACK2">
این محصول در حال حاضر غیرفعال است
</span>
</div>
<Button
variant="dark"
size="sm"
className="text-B10 h-8 rounded-md"
onClick={() => setIsChangeStatusDialogOpen(true)}
>
فعال کردن
</Button>
</div>
) : !isOwner ? (
<div className="lg:hidden">
<ProductActions productId={product.id || ""} />
</div>
) : null}
{/* Desktop: title + rating + price */}
<div className="hidden lg:flex flex-col gap-3 mt-1">
<h1 className="text-[26px] font-extrabold leading-snug">
{product.title}
</h1>
<div className="flex items-center gap-2.5 text-[13.5px] text-BLACK2">
<span className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
size={15}
className={
i <= Math.round(avgRating)
? "fill-BLACK text-BLACK"
: "text-GRAY3"
}
/>
))}
</span>
{avgRating > 0 ? (
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
) : null}
<span className="w-1 h-1 rounded-full bg-GRAY2" />
<span>{reviewCount} دیدگاه</span>
</div>
<div className="flex items-center gap-3 mt-1">
<b className="text-[28px] font-extrabold">
{formatPrice(currentDiscountPrice || currentPrice)}
<span className="text-[15px] font-semibold mr-1">تومان</span>
</b>
{currentDiscountPrice ? (
<del className="text-GRAY text-[17px]">
{formatPrice(currentPrice)}
</del>
) : null}
</div>
</div>
{/* Mobile: title + description */}
<div className="lg:hidden">
<ProductInfo
productDiscountPercent={Number(
!currentDiscountPrice ||
!currentPrice ||
Number(currentDiscountPrice) === Number(currentPrice)
? 0
: (
((Number(currentPrice) - Number(currentDiscountPrice)) /
Number(currentPrice)) *
100
).toFixed(2)
)}
product={{ ...product, isActive: productActiveStatus }}
/>
</div>
{/* Mobile: rating (only when > 0) */}
{!isOwner && avgRating > 0 ? (
<div className="lg:hidden flex items-center gap-2 px-4 mt-3 text-[13px] text-BLACK2">
<span className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
size={15}
className={
i <= Math.round(avgRating)
? "fill-BLACK text-BLACK"
: "text-GRAY3"
}
/>
))}
</span>
<b className="text-BLACK">{avgRating.toFixed(1)}</b>
<span className="w-1 h-1 rounded-full bg-GRAY2" />
<span>{reviewCount} دیدگاه</span>
</div>
) : null}
{hasAnyStock ? (
<>
{hasColorVariants && (
<ColorSelector
colors={inStockColors.map(
(color) =>
(color.info as unknown as { detail: string })?.detail
)}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
)}
{hasSizeVariants && (
<SizeSelector
sizes={inStockSizes.map((size) => size.value || "")}
selectedSize={selectedSize}
setSelectedSize={setSelectedSize}
onSizeGuide={
hasSizeChart
? () => setIsSizeGuideOpen(true)
: undefined
}
/>
)}
</>
) : (
<div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
<p className="text-B18 font-bold">موجود نیست</p>
</div>
)}
{isOwner && (
<div className="w-full p-4 flex justify-center items-center lg:justify-start lg:px-0">
<Button
variant="dark"
size="sm"
className="text-B10 h-8 rounded-md bg-VITROWN_BLUE text-white"
onClick={() => {
navigate(`/product/${product.id}?mode=store`);
}}
>
<Eye size={16} className="text-WHITE" />
دیدن محصول از نگاه مشتری
</Button>
</div>
)}
{/* Desktop buy row + assurances */}
{!isOwner && (
<div className="hidden lg:block">
<div className="flex items-center gap-3 mt-6">
<button
type="button"
onClick={handleChat}
aria-label="گفتگو با فروشنده"
className="w-12 h-12 shrink-0 rounded-xl border border-GRAY3 flex items-center justify-center text-BLACK hover:bg-WHITE2 transition-colors"
>
<MessageCircle size={22} />
</button>
<Button
variant="dark"
size="lg"
className="flex-1 bg-BLACK text-white font-bold gap-2"
disabled={isOutOfStock || addToCartMutation.isPending}
onClick={handleDesktopAddToCart}
>
{isOutOfStock ? (
"ناموجود"
) : (
<>
<ShoppingCart size={20} />
افزودن به سبد خرید
</>
)}
</Button>
<div className="flex items-center shrink-0 border border-GRAY3 rounded-xl overflow-hidden h-12">
<button
type="button"
onClick={() => setQty((q) => q + 1)}
className="w-11 h-full hover:bg-WHITE2 text-lg"
aria-label="افزایش"
>
+
</button>
<span className="w-10 text-center font-bold">{qty}</span>
<button
type="button"
onClick={() => setQty((q) => Math.max(1, q - 1))}
className="w-11 h-full hover:bg-WHITE2 text-lg"
aria-label="کاهش"
>
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-3 mt-6">
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<Truck size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">ارسال سریع</b>
<small className="text-GRAY text-[11.5px]">
۲ تا ۴ روز کاری
</small>
</div>
</div>
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<RotateCcw size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">۷ روز بازگشت</b>
<small className="text-GRAY text-[11.5px]">
ضمانت تعویض کالا
</small>
</div>
</div>
<div className="flex items-start gap-2.5 bg-WHITE2 rounded-xl p-3.5">
<ShieldCheck size={22} className="shrink-0 mt-0.5" />
<div>
<b className="text-[13px] block">پرداخت امن</b>
<small className="text-GRAY text-[11.5px]">
تضمین اصالت کالا
</small>
</div>
</div>
</div>
</div>
)}
{/* Buy box (fixed bottom bar — mobile only) */}
{!isOwner && (
<div className="lg:hidden">
<BottomBar
currentPrice={currentPrice}
currentDiscountPrice={currentDiscountPrice}
currentStock={currentStock}
product={product}
selectedColor={
inStockColors.find(
(color) =>
(color.info as unknown as { detail: string })?.detail ===
selectedColor
)?.value || null
}
selectedSize={selectedSize}
/>
</div>
)}
</div>
</div>
{/* Full-width below the fold: description, contact, reviews, similar */}
{productActiveStatus && (
<>
<ProductDescription description={product.description} />
{!isOwner && (
<div className="lg:hidden">
<ContactButton product={product} />
</div>
)}
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
{/* Similar Products Section */}
{!isOwner && (
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6 lg:max-w-none">
<div className="w-full px-4 lg:px-0">
<p className="text-B16 lg:text-B24 font-bold">محصولات مشابه</p>
<p className="text-R14 text-GRAY">
محصولات مرتبط که ممکن است بپسندید
</p>
</div>
<MondrianProductList
products={
similarProductsData?.pages
?.flatMap((page) => page.results || [])
.map((product) => ({
id: product?.id || "",
title: product?.title || "",
images: Array.isArray(product?.imageUrls)
? product.imageUrls
: [],
discountPercent: product?.discountPercent || 0,
discountPrice: product?.discountPrice || 0,
basePrice: product?.basePrice || 0,
})) || []
}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoadingSimilar}
isError={isErrorSimilar}
refetch={refetchSimilar}
emptyState={{
title: "محصول مشابهی یافت نشد",
description: "در حال حاضر محصول مشابهی برای نمایش وجود ندارد",
}}
/>
</section>
)}
</>
)}
{/* Desktop overlay/buy modals */}
<LoginRequiredDialog
loginTitle="برای ادامه ابتدا باید وارد حساب کاربری خود شوید."
isOpen={isLoginOpen}
onOpenChange={setIsLoginOpen}
/>
<AIPromoteModal
isOpen={isAIOpen}
onClose={() => setIsAIOpen(false)}
productId={product.id || ""}
/>
{hasSizeChart && product.sizeChart && (
<SizeGuideDialog
isOpen={isSizeGuideOpen}
onOpenChange={setIsSizeGuideOpen}
sizeChart={product.sizeChart}
/>
)}
</div>
);
});