feat(product-detail): ProductDescription section + strict discount guard + category types
- app/components/product/ProductDescription.tsx: new below-fold section with a "توضیحات محصول" heading, whitespace-pre-line so multi-line seller copy (Instagram scrapes, victorwear WooCommerce imports) renders with its bullet-list line breaks preserved. Renders on both mobile and desktop; nothing shown when the product has no description. - app/components/product/ProductInfo.tsx: removed the collapsed single-<p> mobile description; the new section replaces it. - app/components/product/ProductDetailView.tsx: pricingOf now hides the discount when discount_price >= price (was only hiding when equal), so a stale row where the discount price accidentally matches or exceeds the base can never render as a fake promo. Mounts ProductDescription just above the mobile Contact button and reviews. - app/components/product/index.ts: export ProductDescription. - src/api/types/models/index.ts: ProductCategory now carries readonly parentId / parentName so the two-step category picker can build the parent→sub tree from a flat category list without an extra fetch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
83b93fe523
commit
56fcce35c8
33
app/components/product/ProductDescription.tsx
Normal file
33
app/components/product/ProductDescription.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { AlignRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface ProductDescriptionProps {
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full product description block. Preserves the seller's line breaks (bullet
|
||||||
|
* lists from Instagram/WooCommerce imports come in as `\n`-separated lines),
|
||||||
|
* with a proper heading so it reads as its own section on the detail page.
|
||||||
|
*
|
||||||
|
* Renders nothing when no description is set — the section header shouldn't
|
||||||
|
* appear on products the seller hasn't filled out yet.
|
||||||
|
*/
|
||||||
|
export function ProductDescription({ description }: ProductDescriptionProps) {
|
||||||
|
const body = (description || "").trim();
|
||||||
|
if (!body) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full px-4 lg:px-0 my-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<AlignRight size={18} className="text-BLACK" />
|
||||||
|
<h2 className="text-B16 lg:text-B20 font-bold">توضیحات محصول</h2>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-R14 lg:text-[15px] leading-8 text-BLACK2 whitespace-pre-line"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import {
|
|||||||
ProductImageCarousel,
|
ProductImageCarousel,
|
||||||
ProductActions,
|
ProductActions,
|
||||||
ProductInfo,
|
ProductInfo,
|
||||||
|
ProductDescription,
|
||||||
ColorSelector,
|
ColorSelector,
|
||||||
SizeSelector,
|
SizeSelector,
|
||||||
ContactButton,
|
ContactButton,
|
||||||
@ -138,7 +139,12 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
discountPrice:
|
discountPrice:
|
||||||
v?.discountPrice &&
|
v?.discountPrice &&
|
||||||
v.discountPrice !== "0" &&
|
v.discountPrice !== "0" &&
|
||||||
Number(v.discountPrice) !== Number(v.price)
|
// Only surface as a "discount" when it's a real saving. A value equal
|
||||||
|
// to (or somehow larger than) `price` is stale/mis-entered state, not a
|
||||||
|
// discount — sellers occasionally end up with matching numbers when
|
||||||
|
// they set "no discount" on edit, and rendering a strikethrough on that
|
||||||
|
// fakes a promo the seller didn't intend.
|
||||||
|
Number(v.discountPrice) < Number(v.price)
|
||||||
? v.discountPrice
|
? v.discountPrice
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
@ -713,9 +719,10 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full-width below the fold: contact, reviews, similar */}
|
{/* Full-width below the fold: description, contact, reviews, similar */}
|
||||||
{productActiveStatus && (
|
{productActiveStatus && (
|
||||||
<>
|
<>
|
||||||
|
<ProductDescription description={product.description} />
|
||||||
{!isOwner && (
|
{!isOwner && (
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<ContactButton product={product} />
|
<ContactButton product={product} />
|
||||||
|
|||||||
@ -25,6 +25,8 @@ export const ProductInfo = ({
|
|||||||
) : null}
|
) : null}
|
||||||
<p className="text-B18 lg:text-[26px] 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>
|
{/* Description moved into the dedicated ProductDescription section below
|
||||||
|
the buy actions so multi-line seller copy renders with line breaks and
|
||||||
|
appears on desktop too. */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export { ProductHeader } from "./ProductHeader";
|
|||||||
export { ProductImageCarousel } from "./ProductImageCarousel";
|
export { ProductImageCarousel } from "./ProductImageCarousel";
|
||||||
export { ProductActions } from "./ProductActions";
|
export { ProductActions } from "./ProductActions";
|
||||||
export { ProductInfo } from "./ProductInfo";
|
export { ProductInfo } from "./ProductInfo";
|
||||||
|
export { ProductDescription } from "./ProductDescription";
|
||||||
export { ColorSelector } from "./ColorSelector";
|
export { ColorSelector } from "./ColorSelector";
|
||||||
export { SizeSelector } from "./SizeSelector";
|
export { SizeSelector } from "./SizeSelector";
|
||||||
export { ContactButton } from "./ContactButton";
|
export { ContactButton } from "./ContactButton";
|
||||||
|
|||||||
@ -3525,6 +3525,18 @@ export interface ProductCategory {
|
|||||||
* @memberof ProductCategory
|
* @memberof ProductCategory
|
||||||
*/
|
*/
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
/**
|
||||||
|
* UUID of this category's parent (null for top-level parents).
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ProductCategory
|
||||||
|
*/
|
||||||
|
readonly parentId?: string | null;
|
||||||
|
/**
|
||||||
|
* Human-readable name of the parent (null for top-level parents).
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ProductCategory
|
||||||
|
*/
|
||||||
|
readonly parentName?: string | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user