feat(cart): mockup-style item rows + separate-shipping note

- CartItem: compact row — image + title + رنگ/سایز attribute chips
  (circular color dot) + a bottom row with the stepper and the effective
  price plus struck original. Drops the verbose label:value rows and the
  product-code line. Shared with the desktop detail column (verified).
- cart list: add a green «ارسال جداگانه» badge per seller-cart, matching
  the mockup's per-seller grouping.

Keeps the existing list -> detail checkout flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-06-27 23:16:42 +03:30
parent e6574b1e9e
commit 254fed4a99
2 changed files with 102 additions and 131 deletions

View File

@ -1,4 +1,4 @@
import { Check, Trash } from "lucide-react"; import { Trash } from "lucide-react";
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { QuantityControl } from "./QuantityControl"; import { QuantityControl } from "./QuantityControl";
import { formatPrice } from "../../utils/helpers"; import { formatPrice } from "../../utils/helpers";
@ -34,145 +34,113 @@ export const CartItem = memo(function CartItem({
); );
}, [item.productVariant?.discountPrice, item.productVariant?.price]); }, [item.productVariant?.discountPrice, item.productVariant?.price]);
// Memoize video check
const isVideoMedia = useMemo( const isVideoMedia = useMemo(
() => isVideo(item.productVariant?.productImage || ""), () => isVideo(item.productVariant?.productImage || ""),
[item.productVariant?.productImage] [item.productVariant?.productImage]
); );
const color =
item.productVariant?.color?.value !== "UNSELECTED"
? item.productVariant?.color?.info?.detail || ""
: "";
const size =
item.productVariant?.size?.value !== "UNSELECTED"
? item.productVariant?.size?.info?.detail || ""
: "";
const effectivePrice =
item.productVariant?.discountPrice || item.productVariant?.price || "";
const inStock =
!!item?.productVariant?.stock && item.productVariant.stock > 0;
return ( return (
<div <div className={`flex gap-3 p-4 w-full ${hasBorder && "border-b"}`}>
className={`flex p-4 gap-4 w-full flex-col ${hasBorder && "border-b"}`} {isVideoMedia ? (
> <video
<div className="flex gap-2 w-full items-center"> src={item.productVariant?.productImage}
{isVideoMedia ? ( className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
<video muted
src={item.productVariant?.productImage} loop
className="rounded-[5px] w-[116px] h-[116px] object-cover" autoPlay
muted playsInline
loop />
autoPlay ) : (
playsInline <img
/> src={item.productVariant?.productImage}
) : ( alt={item.productVariant?.productTitle || ""}
<img className="rounded-[11px] w-[88px] h-[110px] object-cover shrink-0"
src={item.productVariant?.productImage} loading="lazy"
alt={item.productVariant?.productTitle || ""} />
className="rounded-[5px] w-[116px] h-[116px] object-cover" )}
loading="lazy"
/> <div className="flex flex-col flex-1 min-w-0">
<h4 className="text-B14 font-bold line-clamp-2">
{item.productVariant?.productTitle || ""}
</h4>
{/* Attribute chips */}
{(color || size) && (
<div className="flex gap-1.5 flex-wrap mt-2">
{color && (
<span className="inline-flex items-center gap-1.5 text-R12 text-BLACK2 bg-WHITE3 rounded-[7px] px-2 py-1">
<span
className="w-3.5 h-3.5 rounded-full border border-WHITE3"
style={{ backgroundColor: color }}
/>
رنگ
</span>
)}
{size && (
<span className="text-R12 text-BLACK2 bg-WHITE3 rounded-[7px] px-2 py-1">
سایز {size}
</span>
)}
</div>
)} )}
<div className="flex flex-col gap-2">
<p className="text-B14 font-bold">
{item.productVariant?.productTitle || ""}
</p>
<p className="text-R12 text-GRAY line-clamp-1">
کد کالا: {item.productVariant?.id || ""}
</p>
</div>
</div>
<ColorSelector {/* Bottom row: stepper + price */}
color={ {inStock ? (
item.productVariant?.color?.value !== "UNSELECTED" <div className="flex items-center gap-3 mt-auto pt-3">
? item.productVariant?.color?.info?.detail || "" <QuantityControl
: "" isPending={isLoading}
} quantity={item.quantity}
/> maxQuantity={item.productVariant?.stock || 0}
<SizeSelector onIncrease={() =>
size={ onQuantityChange(item.id || "", item.quantity + 1)
item.productVariant?.size?.value !== "UNSELECTED" }
? item.productVariant?.size?.info?.detail || "" onDecrease={() =>
: "" onQuantityChange(item.id || "", item.quantity - 1)
} }
/> onRemove={() => onRemoveItem(item.id || "")}
/>
<div className="flex w-full items-center justify-between"> <div className="ms-auto text-left">
<p className="text-B14 font-bold">قیمت واحد :</p> <b className="text-B14 font-bold">
<p className="text-B14 font-bold"> {formatPrice(effectivePrice)}
{formatPrice(item.productVariant?.price || "")} تومان <span className="text-R10 font-semibold text-BLACK2"> ت</span>
</p> </b>
</div> {discountPercent ? (
<del className="block text-R10 text-GRAY">
{discountPercent ? ( {formatPrice(item.productVariant?.price || "")}
<div className="flex w-full items-center justify-between"> </del>
<div className="flex items-center gap-2"> ) : null}
<p className="text-B14 font-bold">مقدار تخفیف :</p>
<div className="bg-BLACK rounded-full text-WHITE text-R14 px-2 py-1">
{discountPercent}%
</div> </div>
</div> </div>
<p className="text-B14 font-bold"> ) : (
{formatPrice( <div className="flex w-full items-center justify-between mt-auto pt-3">
( <p className="text-R12 font-bold text-RED">
Number(item.productVariant?.price) - در حال حاضر موجود نیست
Number(item.productVariant?.discountPrice) </p>
).toString() <Button
)}{" "} variant="dark"
تومان className="flex items-center gap-2 bg-red-500"
</p> size="sm"
</div> onClick={() => onRemoveItem(item.id || "")}
) : null} >
<Trash size={16} />
{item?.productVariant?.stock && item?.productVariant?.stock > 0 ? ( </Button>
<div className="flex w-full items-center justify-between"> </div>
<div className="flex items-center gap-2"></div> )}
<QuantityControl </div>
isPending={isLoading}
quantity={item.quantity}
maxQuantity={item.productVariant?.stock || 0}
onIncrease={() =>
onQuantityChange(item.id || "", item.quantity + 1)
}
onDecrease={() =>
onQuantityChange(item.id || "", item.quantity - 1)
}
onRemove={() => onRemoveItem(item.id || "")}
/>
</div>
) : (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">محصول در حال حاضر موجود نیست</p>
<Button
variant="dark"
className="flex items-center gap-2 bg-red-500"
size="sm"
onClick={() => onRemoveItem(item.id || "")}
>
<Trash size={16} />
</Button>
</div>
)}
</div> </div>
); );
}); });
// Color Selector Component
const ColorSelector = ({ color }: { color: string }) => (
<>
{color && (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">رنگ :</p>
<div
className="w-10 h-10 rounded-[7px] flex items-center justify-center border border-BLACK"
style={{
backgroundColor: color,
}}
>
<Check size={24} className="text-WHITE" />
</div>
</div>
)}
</>
);
// Size Selector Component
const SizeSelector = ({ size }: { size: string }) => (
<>
{size && (
<div className="flex w-full items-center justify-between">
<p className="text-B14 font-bold">سایز :</p>
<p className="text-BLACK text-R12">{size}</p>
</div>
)}
</>
);

View File

@ -116,6 +116,9 @@ export default function CartIndex() {
<p className="text-B12 font-bold"> <p className="text-B12 font-bold">
{cart.seller?.name} {cart.seller?.name}
</p> </p>
<span className="ms-auto shrink-0 text-R10 font-semibold text-green-600">
ارسال جداگانه
</span>
</a> </a>
<p className="text-B12 font-bold line-clamp-1"> <p className="text-B12 font-bold line-clamp-1">
{cart.items {cart.items