Desktop product page: match design (gallery overlays + clean info column)

Gallery (desktop):
- portrait 3/4 box, object-contain so the photo isn't cropped
- prev/next arrows on the carousel
- save (heart) overlay top-left; AI try-on button "آنلاین پرو کن" at image bottom

Info column (desktop): hide the mobile action icon row (incl. share); clean
layout — title, star rating + review count, prominent price with old/strike,
color + size, buy row (chat + add-to-cart + qty stepper), assurance cards.
Mobile keeps its existing components (ProductActions / ProductInfo / BottomBar /
ContactButton are lg:hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-06-14 23:31:58 +03:30
parent fa65db8804
commit 5c64bbd66a
2 changed files with 326 additions and 33 deletions

View File

@ -11,12 +11,37 @@ import {
BottomBar, BottomBar,
} from "./index"; } from "./index";
import { ProductDetail as ProductDetailType } from "../../../src/api/types"; import { ProductDetail as ProductDetailType } from "../../../src/api/types";
import { Eye, EyeOff } from "lucide-react"; import {
Eye,
EyeOff,
Heart,
Sparkles,
Star,
ShoppingCart,
Truck,
RotateCcw,
ShieldCheck,
MessageCircle,
} from "lucide-react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useNavigate, Link } from "@remix-run/react"; import { useNavigate, Link } from "@remix-run/react";
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks"; import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
import MondrianProductList from "../MondrianProductList"; import MondrianProductList from "../MondrianProductList";
import SellerLogo from "../SellerLogo"; 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 { interface ProductDetailViewProps {
product: ProductDetailType; product: ProductDetailType;
@ -194,6 +219,83 @@ export const ProductDetailView = memo(function ProductDetailView({
]); ]);
const navigate = useNavigate(); 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 ( return (
<div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12"> <div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
{!isOwner && ( {!isOwner && (
@ -227,7 +329,7 @@ export const ProductDetailView = memo(function ProductDetailView({
{/* Top section: gallery | info (two columns on desktop) */} {/* Top section: gallery | info (two columns on desktop) */}
<div className="lg:grid lg:grid-cols-2 lg:gap-10 lg:items-start"> <div className="lg:grid lg:grid-cols-2 lg:gap-10 lg:items-start">
{/* Gallery */} {/* Gallery */}
<div className="lg:rounded-[14px] lg:overflow-hidden lg:shadow-sm lg:sticky lg:top-[88px]"> <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 <ProductImageCarousel
images={product.imageUrls || []} images={product.imageUrls || []}
productId={product.id} productId={product.id}
@ -240,6 +342,31 @@ export const ProductDetailView = memo(function ProductDetailView({
isChangeStatusDialogOpen={isChangeStatusDialogOpen} isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen} 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> </div>
{/* Info */} {/* Info */}
@ -279,22 +406,66 @@ export const ProductDetailView = memo(function ProductDetailView({
</Button> </Button>
</div> </div>
) : !isOwner ? ( ) : !isOwner ? (
<ProductActions productId={product.id || ""} /> <div className="lg:hidden">
<ProductActions productId={product.id || ""} />
</div>
) : null} ) : null}
<ProductInfo
productDiscountPercent={Number( {/* Desktop: title + rating + price */}
!currentDiscountPrice || <div className="hidden lg:flex flex-col gap-3 mt-1">
!currentPrice || <h1 className="text-[26px] font-extrabold leading-snug">
Number(currentDiscountPrice) === Number(currentPrice) {product.title}
? 0 </h1>
: ( <div className="flex items-center gap-2.5 text-[13.5px] text-BLACK2">
((Number(currentPrice) - Number(currentDiscountPrice)) / <span className="flex items-center">
Number(currentPrice)) * {[1, 2, 3, 4, 5].map((i) => (
100 <Star
).toFixed(2) key={i}
)} size={15}
product={{ ...product, isActive: productActiveStatus }} 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 ? ( {hasAnyStock ? (
<> <>
@ -338,22 +509,104 @@ export const ProductDetailView = memo(function ProductDetailView({
</div> </div>
)} )}
{/* Buy box (fixed bottom bar on mobile, inline on desktop) */} {/* Desktop buy row + assurances */}
{!isOwner && ( {!isOwner && (
<BottomBar <div className="hidden lg:block">
currentPrice={currentPrice} <div className="flex items-center gap-3 mt-6">
currentDiscountPrice={currentDiscountPrice} <button
currentStock={currentStock} type="button"
product={product} onClick={handleChat}
selectedColor={ aria-label="گفتگو با فروشنده"
inStockColors.find( className="w-12 h-12 shrink-0 rounded-xl border border-GRAY3 flex items-center justify-center text-BLACK hover:bg-WHITE2 transition-colors"
(color) => >
(color.info as unknown as { detail: string })?.detail === <MessageCircle size={22} />
selectedColor </button>
)?.value || null <Button
} variant="dark"
selectedSize={selectedSize} 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>
</div> </div>
@ -361,7 +614,11 @@ export const ProductDetailView = memo(function ProductDetailView({
{/* Full-width below the fold: contact, reviews, similar */} {/* Full-width below the fold: contact, reviews, similar */}
{productActiveStatus && ( {productActiveStatus && (
<> <>
{!isOwner && <ContactButton product={product} />} {!isOwner && (
<div className="lg:hidden">
<ContactButton product={product} />
</div>
)}
<ReviewSection productId={product.id || ""} isOwner={isOwner} /> <ReviewSection productId={product.id || ""} isOwner={isOwner} />
{/* Similar Products Section */} {/* Similar Products Section */}
{!isOwner && ( {!isOwner && (
@ -402,6 +659,18 @@ export const ProductDetailView = memo(function ProductDetailView({
)} )}
</> </>
)} )}
{/* Desktop overlay/buy modals */}
<LoginRequiredDialog
loginTitle="برای ادامه ابتدا باید وارد حساب کاربری خود شوید."
isOpen={isLoginOpen}
onOpenChange={setIsLoginOpen}
/>
<AIPromoteModal
isOpen={isAIOpen}
onClose={() => setIsAIOpen(false)}
productId={product.id || ""}
/>
</div> </div>
); );
}); });

View File

@ -25,6 +25,8 @@ import {
EyeOff, EyeOff,
Eye, Eye,
ArrowRight, ArrowRight,
ChevronLeft,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { import {
useDeleteProduct, useDeleteProduct,
@ -274,6 +276,28 @@ export const ProductImageCarousel = ({
</div> </div>
)} )}
{/* Prev / next arrows (desktop) */}
{images.length > 1 && (
<>
<button
type="button"
onClick={() => api?.scrollPrev()}
aria-label="تصویر قبلی"
className="hidden lg:flex absolute top-1/2 right-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronRight size={20} />
</button>
<button
type="button"
onClick={() => api?.scrollNext()}
aria-label="تصویر بعدی"
className="hidden lg:flex absolute top-1/2 left-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronLeft size={20} />
</button>
</>
)}
{/* Settings Drawer */} {/* Settings Drawer */}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerContent> <DrawerContent>