- 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>
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
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>
|
||
);
|
||
});
|