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,
|
||||
} from "./index";
|
||||
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 { 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;
|
||||
@ -194,6 +219,83 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
]);
|
||||
|
||||
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 && (
|
||||
@ -227,7 +329,7 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
{/* 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]">
|
||||
<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}
|
||||
@ -240,6 +342,31 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
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 */}
|
||||
@ -279,22 +406,66 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
</Button>
|
||||
</div>
|
||||
) : !isOwner ? (
|
||||
<ProductActions productId={product.id || ""} />
|
||||
<div className="lg:hidden">
|
||||
<ProductActions productId={product.id || ""} />
|
||||
</div>
|
||||
) : null}
|
||||
<ProductInfo
|
||||
productDiscountPercent={Number(
|
||||
!currentDiscountPrice ||
|
||||
!currentPrice ||
|
||||
Number(currentDiscountPrice) === Number(currentPrice)
|
||||
? 0
|
||||
: (
|
||||
((Number(currentPrice) - Number(currentDiscountPrice)) /
|
||||
Number(currentPrice)) *
|
||||
100
|
||||
).toFixed(2)
|
||||
)}
|
||||
product={{ ...product, isActive: productActiveStatus }}
|
||||
/>
|
||||
|
||||
{/* 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 ? (
|
||||
<>
|
||||
@ -338,22 +509,104 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buy box (fixed bottom bar on mobile, inline on desktop) */}
|
||||
{/* Desktop buy row + assurances */}
|
||||
{!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 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>
|
||||
@ -361,7 +614,11 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
{/* Full-width below the fold: contact, reviews, similar */}
|
||||
{productActiveStatus && (
|
||||
<>
|
||||
{!isOwner && <ContactButton product={product} />}
|
||||
{!isOwner && (
|
||||
<div className="lg:hidden">
|
||||
<ContactButton product={product} />
|
||||
</div>
|
||||
)}
|
||||
<ReviewSection productId={product.id || ""} isOwner={isOwner} />
|
||||
{/* Similar Products Section */}
|
||||
{!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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -25,6 +25,8 @@ import {
|
||||
EyeOff,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useDeleteProduct,
|
||||
@ -274,6 +276,28 @@ export const ProductImageCarousel = ({
|
||||
</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 */}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerContent>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user