Vitron-Front/app/components/HorizontalProductList.tsx
Arda Samadi 11656c2996 feat(home): seller brand chip (logo + name) on product tiles
Show the seller's brand on the home product cards, like the design:
a small logo circle + name overlaid above the title.

- new SellerChip component: renders nothing without a name; falls back to
  the seller's initial when no logo is present (e.g. before the backend
  ships seller_logo). seller_name is already returned today, so the name
  shows immediately.
- thread sellerName/sellerLogo/sellerUsername through ProductHomepage type,
  the home discounts + for-you mappings, and the MondrianProductList /
  HorizontalProductList card interfaces + overlays.

Only renders where seller data is mapped (home), so seller-page and
store-dashboard tiles are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:38:44 +03:30

298 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Link } from "@remix-run/react";
import { useRef, useState, useEffect } from "react";
import { motion } from "framer-motion";
import { ChevronLeft, ChevronRight, Images } from "lucide-react";
import { Skeleton } from "./ui/skeleton";
import { ErrorState } from "./UiProvider";
import discountRed from "~/assets/icons/product/discount-red.svg";
import placeholderImage from "~/assets/icons/product.png";
import SellerChip from "./SellerChip";
interface HorizontalProduct {
id?: string;
title?: string;
images?: string[];
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
sellerName?: string;
sellerLogo?: string | null;
sellerUsername?: string;
}
const HorizontalProductList = ({
products,
isLoading,
isError,
refetch,
storeId,
storePage,
}: {
products: HorizontalProduct[];
isLoading: boolean;
isError: boolean;
refetch: () => void;
storeId?: string;
storePage?: boolean;
}) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftButton, setShowLeftButton] = useState(false);
const [showRightButton, setShowRightButton] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const totalPages = Math.ceil(products.length / 2);
const checkScrollButtons = () => {
if (!scrollContainerRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
// Calculate max scroll position
const maxScroll = scrollWidth - clientWidth;
// For RTL, scrollLeft might be negative or start from max
// Check if we can scroll in each direction
const canScrollRight = Math.abs(scrollLeft) > 100;
const canScrollLeft = Math.abs(scrollLeft) < maxScroll - 100;
setShowRightButton(canScrollRight);
setShowLeftButton(canScrollLeft);
// Calculate current page based on scroll position
const page = Math.round(Math.abs(scrollLeft) / clientWidth);
setCurrentPage(page);
};
useEffect(() => {
// Small delay to ensure content is rendered
const timer = setTimeout(() => {
checkScrollButtons();
}, 100);
const container = scrollContainerRef.current;
if (container) {
container.addEventListener("scroll", checkScrollButtons);
window.addEventListener("resize", checkScrollButtons);
return () => {
container.removeEventListener("scroll", checkScrollButtons);
window.removeEventListener("resize", checkScrollButtons);
};
}
return () => clearTimeout(timer);
}, [products]);
const scrollToNext = () => {
if (!scrollContainerRef.current) return;
const container = scrollContainerRef.current;
const scrollAmount = container.clientWidth;
container.scrollBy({ left: scrollAmount, behavior: "smooth" });
};
const scrollToPrev = () => {
if (!scrollContainerRef.current) return;
const container = scrollContainerRef.current;
const scrollAmount = container.clientWidth;
container.scrollBy({ left: -scrollAmount, behavior: "smooth" });
};
if (isLoading) {
return <HorizontalSkeleton />;
}
if (isError) {
return (
<div className="px-4">
<ErrorState onRetry={refetch} />
</div>
);
}
if (products.length === 0) {
return null;
}
return (
<div className="w-full flex flex-col gap-3">
{/* Scroll Container */}
<div
ref={scrollContainerRef}
className="w-full overflow-x-auto scrollbar-hide snap-x snap-mandatory px-4"
>
<div className="flex" style={{ width: "max-content" }}>
{products.map((product, index) => (
<div
key={product.id || index}
className="flex flex-col gap-2 pr-4 snap-start"
style={{ width: "calc(50vw - 14px)", maxWidth: "220px" }}
>
<Link
to={`${storePage ? `/store/${storeId}/product/${product.id}` : `/product/${product.id}`}`}
className="block w-full"
>
<div className="relative rounded-lg overflow-hidden h-[180px]">
{/* Single Image Display */}
<ProductThumbnail
image={product.images?.[0]}
imageCount={product.images?.length || 0}
title={product.title}
/>
{/* Discount badge */}
{product.discountPercent && product.discountPercent !== 0 && (
<DiscountBadge discountPercent={product.discountPercent} />
)}
{/* Overlay info */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
<SellerChip
name={product.sellerName}
logo={product.sellerLogo}
/>
<div className="flex w-full justify-between gap-1">
{/* Title */}
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
{product.title}
</h3>
{/* Price */}
<div className="flex flex-col items-center gap-0 relative">
{product.discountPrice && (
<p className="text-white text-xs font-bold">
{`${product.discountPrice.toLocaleString()}`}
<span className="text-white text-[8px] mr-[2px]">
تومان
</span>
</p>
)}
{product.basePrice && product.discountPercent ? (
<p className="text-white/80 text-[10px] line-through absolute bottom-4 left-0">
{product.basePrice.toLocaleString()}
</p>
) : null}
</div>
</div>
</div>
</div>
</Link>
</div>
))}
</div>
</div>
{/* Navigation Buttons Row */}
{products.length > 2 && (
<div className="flex items-center justify-center gap-3 px-4">
<motion.button
whileHover={!showRightButton ? {} : { scale: 1.1 }}
whileTap={!showRightButton ? {} : { scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
onClick={scrollToNext}
disabled={!showRightButton}
aria-label="Next products"
>
<ChevronRight
size={20}
className={showRightButton ? "text-BLACK" : "text-GRAY"}
/>
</motion.button>
{/* Dots indicator */}
<div className="flex gap-1.5">
{Array.from({ length: totalPages }).map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all duration-300 ${
i === currentPage ? "w-6 bg-BLACK" : "w-1.5 bg-GRAY3"
}`}
/>
))}
</div>
<motion.button
whileHover={!showLeftButton ? {} : { scale: 1.1 }}
whileTap={!showLeftButton ? {} : { scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
onClick={scrollToPrev}
disabled={!showLeftButton}
aria-label="Previous products"
>
<ChevronLeft
size={20}
className={showLeftButton ? "text-BLACK" : "text-GRAY"}
/>
</motion.button>
</div>
)}
</div>
);
};
// Simple thumbnail component - shows only first image with count badge
const ProductThumbnail = ({
image,
imageCount,
title,
}: {
image?: string;
imageCount: number;
title?: string;
}) => {
return (
<>
<img
src={image || placeholderImage}
alt={title || "محصول"}
className={`w-full h-full ${image ? "object-cover" : "object-contain bg-GRAY4"}`}
loading="lazy"
onError={(e) => {
e.currentTarget.src = placeholderImage;
e.currentTarget.style.objectFit = "contain";
e.currentTarget.className = "w-full h-full object-contain bg-GRAY4";
}}
/>
{/* Image count badge - only show if more than 1 image */}
{imageCount > 1 && (
<div className="absolute top-2 left-2 z-10 bg-DARK_OVERLAY/80 text-WHITE px-2 py-1 rounded-full flex items-center gap-1">
<Images size={12} />
<span className="text-[12px] font-medium">{imageCount}</span>
</div>
)}
</>
);
};
const DiscountBadge = ({ discountPercent }: { discountPercent?: number }) => {
return (
<div className="absolute items-center gap-1 flex top-1 right-1 z-10 bg-[#FDFDFD]/75 text-[#FF3B30] py-[2px] px-2 rounded-[15px]">
<p
className="text-B14 font-bold leading-[20px]"
dir="ltr"
style={{ fontFamily: "Arial, sans-serif" }}
>
{discountPercent}
</p>
<img src={discountRed} alt="discount-red" className="w-5 h-5" />
</div>
);
};
const HorizontalSkeleton = () => {
return (
<div className="w-full overflow-x-auto scrollbar-hide">
<div className="flex gap-4 px-4" style={{ width: "max-content" }}>
{Array(6)
.fill(1)
.map((_, index) => (
<Skeleton
key={index}
className="flex-shrink-0"
style={{
width: "calc(50vw - 32px)",
maxWidth: "220px",
height: "180px",
}}
/>
))}
</div>
</div>
);
};
export default HorizontalProductList;