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:
fazli 2026-07-18 13:19:27 +00:00
parent 83b93fe523
commit 56fcce35c8
5 changed files with 61 additions and 6 deletions

View 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>
);
}

View File

@ -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} />

View File

@ -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>
); );

View File

@ -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";

View File

@ -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;
} }
/** /**
* *