Vitron-Front/app/components/product/ProductDetailView.tsx
Arda Samadi 60d6a2a6e4 Desktop: product detail two-column layout
- ProductDetailView: gallery | info two-column grid on desktop with breadcrumb;
  gallery sticky; reviews + similar products full-width below. Mobile stack
  unchanged (only lg: utilities + a moved BottomBar, which is position:fixed so
  its DOM location doesn't affect mobile)
- BottomBar: static inline buy card on desktop (fixed bottom bar on mobile)
- ProductInfo: larger title + tightened top spacing at lg
- MyLayout: desktop gate now includes /product/*

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

391 lines
13 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";
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">
{/* 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>
);
});