Vitron-Front/app/components/HorizontalProductList.tsx
2026-04-29 01:44:16 +03:30

290 lines
9.4 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";
interface HorizontalProduct {
id?: string;
title?: string;
images?: string[];
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
}
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">
<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;