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:
parent
f57fe4e9d6
commit
60d6a2a6e4
@ -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/") ||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user