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 = const isDesktopReady =
p === "/" || p === "/" ||
p.startsWith("/search/") || p.startsWith("/search/") ||
p.startsWith("/product/") ||
p.startsWith("/seller/") || p.startsWith("/seller/") ||
p === "/collections" || p === "/collections" ||
p.startsWith("/collection/") || p.startsWith("/collection/") ||

View File

@ -194,7 +194,7 @@ export const BottomBar = ({
return ( 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 w-full items-center justify-between">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<AddToCartButton <AddToCartButton

View File

@ -13,7 +13,7 @@ import {
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 } from "lucide-react";
import { Button } from "../ui/button"; 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 { useSimilarProductsInfinite } from "~/requestHandler/use-product-hooks";
import MondrianProductList from "../MondrianProductList"; import MondrianProductList from "../MondrianProductList";
@ -194,8 +194,39 @@ export const ProductDetailView = memo(function ProductDetailView({
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className="flex flex-col pb-20"> <div className="flex flex-col pb-20 lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pb-12">
{!isOwner && <ProductHeader product={product} />} {!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="lg:rounded-[14px] lg:overflow-hidden lg:shadow-sm lg:sticky lg:top-[88px]">
<ProductImageCarousel <ProductImageCarousel
images={product.imageUrls || []} images={product.imageUrls || []}
productId={product.id} productId={product.id}
@ -208,9 +239,13 @@ export const ProductDetailView = memo(function ProductDetailView({
isChangeStatusDialogOpen={isChangeStatusDialogOpen} isChangeStatusDialogOpen={isChangeStatusDialogOpen}
setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen} setIsChangeStatusDialogOpen={setIsChangeStatusDialogOpen}
/> />
</div>
{/* Info */}
<div className="flex flex-col">
{/* Inactive Product Status */} {/* Inactive Product Status */}
{!productActiveStatus && isOwner ? ( {!productActiveStatus && isOwner ? (
<div className="flex gap-1 px-4 w-full justify-between items-center mt-2"> <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"> <div className="flex items-center w-full gap-2 p-2 bg-WHITE3 rounded-lg">
<EyeOff size={16} className="text-BLACK2 font-bold" /> <EyeOff size={16} className="text-BLACK2 font-bold" />
<span className="text-B12 font-bold text-BLACK2"> <span className="text-B12 font-bold text-BLACK2">
@ -249,7 +284,8 @@ export const ProductDetailView = memo(function ProductDetailView({
{hasColorVariants && ( {hasColorVariants && (
<ColorSelector <ColorSelector
colors={inStockColors.map( colors={inStockColors.map(
(color) => (color.info as unknown as { detail: string })?.detail (color) =>
(color.info as unknown as { detail: string })?.detail
)} )}
selectedColor={selectedColor} selectedColor={selectedColor}
setSelectedColor={setSelectedColor} setSelectedColor={setSelectedColor}
@ -265,12 +301,12 @@ export const ProductDetailView = memo(function ProductDetailView({
)} )}
</> </>
) : ( ) : (
<div className="flex flex-col gap-4 mt-10 px-4"> <div className="flex flex-col gap-4 mt-10 px-4 lg:px-0">
<p className="text-B18 font-bold">موجود نیست</p> <p className="text-B18 font-bold">موجود نیست</p>
</div> </div>
)} )}
{isOwner && ( {isOwner && (
<div className="w-full p-4 flex justify-center items-center"> <div className="w-full p-4 flex justify-center items-center lg:justify-start lg:px-0">
<Button <Button
variant="dark" variant="dark"
size="sm" size="sm"
@ -284,16 +320,40 @@ export const ProductDetailView = memo(function ProductDetailView({
</Button> </Button>
</div> </div>
)} )}
{/* 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}
/>
)}
</div>
</div>
{/* Full-width below the fold: contact, reviews, similar */}
{productActiveStatus && ( {productActiveStatus && (
<> <>
{!isOwner && <ContactButton product={product} />} {!isOwner && <ContactButton product={product} />}
<ReviewSection productId={product.id || ""} isOwner={isOwner} /> <ReviewSection productId={product.id || ""} isOwner={isOwner} />
{/* Similar Products Section */} {/* Similar Products Section */}
{!isOwner && ( {!isOwner && (
<section className="w-full gap-2 flex flex-col max-w-[100vw] my-6"> <section className="w-full gap-2 flex flex-col max-w-[100vw] my-6 lg:max-w-none">
<div className="w-full px-4"> <div className="w-full px-4 lg:px-0">
<p className="text-B16 font-bold">محصولات مشابه</p> <p className="text-B16 lg:text-B24 font-bold">محصولات مشابه</p>
<p className="text-R14">محصولات مرتبط که ممکن است بپسندید</p> <p className="text-R14 text-GRAY">
محصولات مرتبط که ممکن است بپسندید
</p>
</div> </div>
<MondrianProductList <MondrianProductList
products={ products={
@ -323,25 +383,8 @@ export const ProductDetailView = memo(function ProductDetailView({
/> />
</section> </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> </div>
); );
}); });

View File

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