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>
This commit is contained in:
Arda Samadi 2026-06-14 22:35:06 +03:30
parent f57fe4e9d6
commit 60d6a2a6e4
4 changed files with 150 additions and 106 deletions

View File

@ -34,6 +34,7 @@ const MyLayout = React.memo(({ children }: { children: React.ReactNode }) => {
const isDesktopReady =
p === "/" ||
p.startsWith("/search/") ||
p.startsWith("/product/") ||
p.startsWith("/seller/") ||
p === "/collections" ||
p.startsWith("/collection/") ||

View File

@ -194,7 +194,7 @@ export const BottomBar = ({
return (
<>
<div className="max-w-md mx-auto fixed gap-2 bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row">
<div className="max-w-md mx-auto fixed gap-2 bottom-[calc(50px+env(safe-area-inset-bottom))] left-0 right-0 border-t border-inner-border bg-WHITE p-4 z-50 flex flex-row lg:static lg:max-w-none lg:mx-0 lg:border lg:rounded-xl lg:mt-6 lg:z-auto">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-1">
<AddToCartButton

View File

@ -13,7 +13,7 @@ import {
import { ProductDetail as ProductDetailType } from "../../../src/api/types";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "../ui/button";
import { useNavigate } from "@remix-run/react";
import { useNavigate, Link } from "@remix-run/react";
import { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
import MondrianProductList from "../MondrianProductList";
@ -194,106 +194,166 @@ export const ProductDetailView = memo(function ProductDetailView({
const navigate = useNavigate();
return (
<div className="flex flex-col pb-20">
{!isOwner && <ProductHeader product={product} />}
<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}
/>
{/* Inactive Product Status */}
{!productActiveStatus && isOwner ? (
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2">
<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 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>
) : !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
{/* 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}
/>
)}
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>
)}
{hasSizeVariants && (
<SizeSelector
sizes={inStockSizes.map((size) => size.value || "")}
{/* 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}
setSelectedSize={setSelectedSize}
/>
)}
</>
) : (
<div className="flex flex-col gap-4 mt-10 px-4">
<p className="text-B18 font-bold">موجود نیست</p>
</div>
)}
{isOwner && (
<div className="w-full p-4 flex justify-center items-center">
<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>
)}
</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">
<div className="w-full px-4">
<p className="text-B16 font-bold">محصولات مشابه</p>
<p className="text-R14">محصولات مرتبط که ممکن است بپسندید</p>
<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={
@ -323,25 +383,8 @@ export const ProductDetailView = memo(function ProductDetailView({
/>
</section>
)}
{/* Bottom bar handles variant selection and pricing */}
</>
)}
{!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>
);
});

View File

@ -10,7 +10,7 @@ export const ProductInfo = ({
product,
productDiscountPercent,
}: ProductInfoProps) => (
<div className="flex flex-col gap-2 mt-8 px-4">
<div className="flex flex-col gap-2 mt-8 px-4 lg:mt-2">
<div className="flex gap-3 items-center">
{productDiscountPercent && productDiscountPercent > 0 ? (
<div className="flex gap-[2px] items-center">
@ -23,7 +23,7 @@ export const ProductInfo = ({
{productDiscountPercent && productDiscountPercent > 0 ? (
<div className="w-[6px] h-[6px] rounded-full bg-BLACK" />
) : null}
<p className="text-B18 font-bold">{product.title}</p>
<p className="text-B18 lg:text-[26px] font-bold">{product.title}</p>
</div>
<p className="text-R12 text-GRAY">{product.description}</p>
</div>