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,
|
||||
ProductActions,
|
||||
ProductInfo,
|
||||
ProductDescription,
|
||||
ColorSelector,
|
||||
SizeSelector,
|
||||
ContactButton,
|
||||
@ -138,7 +139,12 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
discountPrice:
|
||||
v?.discountPrice &&
|
||||
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
|
||||
: null,
|
||||
});
|
||||
@ -713,9 +719,10 @@ export const ProductDetailView = memo(function ProductDetailView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-width below the fold: contact, reviews, similar */}
|
||||
{/* Full-width below the fold: description, contact, reviews, similar */}
|
||||
{productActiveStatus && (
|
||||
<>
|
||||
<ProductDescription description={product.description} />
|
||||
{!isOwner && (
|
||||
<div className="lg:hidden">
|
||||
<ContactButton product={product} />
|
||||
|
||||
@ -25,6 +25,8 @@ export const ProductInfo = ({
|
||||
) : null}
|
||||
<p className="text-B18 lg:text-[26px] font-bold">{product.title}</p>
|
||||
</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>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ export { ProductHeader } from "./ProductHeader";
|
||||
export { ProductImageCarousel } from "./ProductImageCarousel";
|
||||
export { ProductActions } from "./ProductActions";
|
||||
export { ProductInfo } from "./ProductInfo";
|
||||
export { ProductDescription } from "./ProductDescription";
|
||||
export { ColorSelector } from "./ColorSelector";
|
||||
export { SizeSelector } from "./SizeSelector";
|
||||
export { ContactButton } from "./ContactButton";
|
||||
|
||||
@ -3525,6 +3525,18 @@ export interface ProductCategory {
|
||||
* @memberof ProductCategory
|
||||
*/
|
||||
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