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:
parent
fa65db8804
commit
5c64bbd66a
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user