290 lines
9.4 KiB
TypeScript
290 lines
9.4 KiB
TypeScript
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;
|