Vitron-Front/app/components/product/ProductDetailView.tsx
Arda Samadi fa65db8804 Desktop fixes: product seller + actions, collection cover, masonry skeleton
- product: show seller (logo + name) in the desktop info column (was missing
  after hiding the mobile header); action icons (save/share/collection/AI) are
  now circular bordered buttons on desktop instead of a bare icon row
- collection detail: cover constrained to a 360px portrait column with
  object-cover (fixes over-long image + side margins); tighter spacing
- masonry: only render the loading skeleton while actively fetching (removes
  the persistent grey block before the footer); sentinel moved above it

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

408 lines
14 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 {
ProductHeader,
ProductImageCarousel,
ProductActions,
ProductInfo,
ColorSelector,
SizeSelector,
ContactButton,
ReviewSection,
BottomBar,
} from "./index";
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
import { Eye, EyeOff } 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";
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]);
// 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]
);
const [selectedColor, setSelectedColor] = useState<string | null>(
hasColorVariants && inStockColors.length > 0
? (inStockColors[0].info as unknown as { detail: string })?.detail
: null
);
const [selectedSize, setSelectedSize] = useState<string | null>(
hasSizeVariants && inStockSizes.length > 0 ? inStockSizes[0].value : null
);
// Handle product status change
const handleProductStatusChange = (newStatus: boolean) => {
setProductActiveStatus(newStatus);
};
const [currentPrice, setCurrentPrice] = useState(product.basePrice);
const [currentDiscountPrice, setCurrentDiscountPrice] = useState<
string | null
>(null);
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) {
setCurrentPrice(matchingVariant.price || product.basePrice);
setCurrentDiscountPrice(
matchingVariant.discountPrice &&
matchingVariant.discountPrice !== "0" &&
Number(matchingVariant.discountPrice) !==
Number(matchingVariant.price)
? matchingVariant.discountPrice
: null
);
setCurrentStock(matchingVariant.stock || null);
} else {
// If no matching variant found, but we have variants and no color/size requirements
// Use the first available variant (for products with no color/size variants)
if (!hasColorVariants && !hasSizeVariants && stockedVariants.length > 0) {
const firstVariant = stockedVariants[0];
setCurrentPrice(firstVariant.price || product.basePrice);
setCurrentDiscountPrice(
firstVariant.discountPrice &&
firstVariant.discountPrice !== "0" &&
Number(firstVariant.discountPrice) !== Number(firstVariant.price)
? firstVariant.discountPrice
: null
);
setCurrentStock(firstVariant.stock || null);
} else {
setCurrentPrice(product.basePrice);
setCurrentDiscountPrice(null);
setCurrentStock(null);
}
}
}, [
selectedColor,
selectedSize,
product,
inStockColors,
hasColorVariants,
hasSizeVariants,
stockedVariants,
]);
const navigate = useNavigate();
return (
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
{!isOwner && (
<div className="lg:hidden">
<ProductHeader product={product} />
</div>
)}
{/* 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="lg:rounded-[14px] lg:overflow-hidden lg:shadow-sm lg:sticky lg:top-[88px]">
<ProductImageCarousel
images={product.imageUrls || []}
productId={product.id}
storeId={product.seller?.username}
isOwner={isOwner}
borderRadius={0}
isActive={productActiveStatus}
controlsIsOn={true}
onStatusChange={handleProductStatusChange}
isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
/>
</div>
{/* Info */}
<div className="flex flex-col">
{/* 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 ? (
<ProductActions productId={product.id || ""} />
) : null}
<ProductInfo
productDiscountPercent={Number(
!currentDiscountPrice ||
!currentPrice ||
Number(currentDiscountPrice) === Number(currentPrice)
? 0
: (
((Number(currentPrice) - Number(currentDiscountPrice)) /
Number(currentPrice)) *
100
).toFixed(2)
)}
product={{ ...product, isActive: productActiveStatus }}
/>
{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}
/>
)}
</>
) : (
<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>
)}
{/* Buy box (fixed bottom bar on mobile, inline on desktop) */}
{!isOwner && (
<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>
{/* Full-width below the fold: contact, reviews, similar */}
{productActiveStatus && (
<>
{!isOwner && <ContactButton product={product} />}
<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>
)}
</>
)}
</div>
);
});