Vitron-Front/app/components/product/ProductDetailView.tsx
fazli 272362cfdf fix(product): detail page price now matches list tiles + fresher feeds
Single-product page showed basePrice with no discount while list tiles
showed the cheapest variant's discounted price. Cause: the detail view
defaulted selectedColor/selectedSize to the first in-stock color and the
first in-stock size independently — a combo that often isn't a real
variant, so no variant matched and it fell back to basePrice / no sale.

- ProductDetailView: derive a `defaultVariant` (cheapest in-stock variant
  by final price, == the tile's min/discount price) and use it for the
  initial selection, the initial displayed price, and the fallback when no
  exact color/size combo is matched. A shared `pricingOf` helper applies
  the same discount rule everywhere. Detail price now matches the tile.
- use-home-hooks: drop staleTime 5m -> 60s on the For You / discounts /
  top-sellers feeds so price/discount edits show up promptly in lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:55:11 +00:00

705 lines
25 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,
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 { findVariantId, formatPrice } from "~/utils/helpers";
import { useToast } from "~/hooks/use-toast";
import { useRootData } from "~/hooks/use-root-data";
import { LoginRequiredDialog } from "../LoginRequiredDialog";
import AIPromoteModal from "../AIPromoteModal";
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]
);
// 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 { 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(product.seller.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">
{!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="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>
</>
)}
</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 ? (
<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>
{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>
)}
{/* 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 || ""}
/>
</div>
);
});