- Product-page chat buttons pass the product id when starting a thread, so it's shared into the conversation as a card the seller can see (and tap through). - MessageBubble renders the product card (image, title, price → product page); card-only messages no longer render an empty bubble. - Inbox preview shows 🛍 <product> instead of "بدون پیام" for product-card threads. ChatProductCard type + product on message/last-message types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
786 lines
28 KiB
TypeScript
786 lines
28 KiB
TypeScript
import { useState, useEffect, useMemo, memo } from "react";
|
||
import {
|
||
ProductImageCarousel,
|
||
ProductActions,
|
||
ProductInfo,
|
||
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" &&
|
||
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: contact, reviews, similar */}
|
||
{productActiveStatus && (
|
||
<>
|
||
{!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>
|
||
);
|
||
});
|