- seller: only the logo overlaps the cover now; name/handle/stats sit below the cover on white (fixes low-contrast name on the gradient) - collections (home rail, /collections grid, /collection cover): portrait 3/4 images so full-body figures aren't cropped; detail cover uses object-contain - MondrianProductList: add IntersectionObserver sentinel for reliable infinite scroll on desktop (window-scroll math missed under the footer) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { Link } from "@remix-run/react";
|
||
import { useRef, useState, useEffect, useCallback } from "react";
|
||
import { motion } from "framer-motion";
|
||
import { ChevronLeft, ChevronRight, Package, ShoppingBag } from "lucide-react";
|
||
import { Skeleton } from "./ui/skeleton";
|
||
import { ErrorState } from "./UiProvider";
|
||
|
||
interface HorizontalCollection {
|
||
id?: string;
|
||
title?: string;
|
||
description?: string | null;
|
||
coverImageUrl?: string | null;
|
||
productCount?: number;
|
||
}
|
||
|
||
const HorizontalCollectionsList = ({
|
||
collections,
|
||
isLoading,
|
||
isError,
|
||
refetch,
|
||
showViewAllButton = false,
|
||
}: {
|
||
collections: HorizontalCollection[];
|
||
isLoading: boolean;
|
||
isError: boolean;
|
||
refetch: () => void;
|
||
showViewAllButton?: boolean;
|
||
}) => {
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
const [showLeftButton, setShowLeftButton] = useState(false);
|
||
const [showRightButton, setShowRightButton] = useState(false);
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
|
||
const totalPages = collections.length;
|
||
|
||
// Helper function to calculate card width consistently
|
||
const getCardWidth = useCallback(() => {
|
||
// Same calculation as scrollToNext/scrollToPrev: 85vw (max 300px) + 16px gap
|
||
return Math.min(window.innerWidth * 0.85, 300) + 16;
|
||
}, []);
|
||
|
||
const checkScrollButtons = useCallback(() => {
|
||
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 card width using the same logic as scroll functions
|
||
const cardWidth = getCardWidth();
|
||
|
||
// Calculate current page based on scroll position
|
||
const page = Math.round(Math.abs(scrollLeft) / cardWidth);
|
||
setCurrentPage(Math.min(page, collections.length - 1));
|
||
}, [collections.length, getCardWidth]);
|
||
|
||
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);
|
||
}, [collections, checkScrollButtons]);
|
||
|
||
const scrollToNext = () => {
|
||
if (!scrollContainerRef.current) return;
|
||
const container = scrollContainerRef.current;
|
||
const cardWidth = getCardWidth();
|
||
container.scrollBy({ left: cardWidth, behavior: "smooth" });
|
||
};
|
||
|
||
const scrollToPrev = () => {
|
||
if (!scrollContainerRef.current) return;
|
||
const container = scrollContainerRef.current;
|
||
const cardWidth = getCardWidth();
|
||
container.scrollBy({ left: -cardWidth, behavior: "smooth" });
|
||
};
|
||
|
||
if (isLoading) {
|
||
return <HorizontalSkeleton />;
|
||
}
|
||
|
||
if (isError) {
|
||
return (
|
||
<div className="px-4">
|
||
<ErrorState onRetry={refetch} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (collections.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 lg:overflow-visible lg:px-0"
|
||
>
|
||
<div
|
||
className="flex lg:grid lg:grid-cols-4 lg:gap-4 lg:!w-full"
|
||
style={{ width: "max-content" }}
|
||
>
|
||
{collections.map((collection, index) => (
|
||
<Link
|
||
key={collection.id || index}
|
||
to={`/collection/${collection.id}`}
|
||
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
|
||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
||
>
|
||
<div className="flex flex-col gap-3 p-3 bg-WHITE2 rounded-2xl border border-inner-border hover:border-gray-300 transition-all duration-300 hover:shadow-lg h-full">
|
||
{/* Collection Image */}
|
||
<div className="relative overflow-hidden rounded-xl w-full h-64 lg:h-auto lg:aspect-[3/4] bg-gray-100">
|
||
{collection.coverImageUrl ? (
|
||
<img
|
||
src={collection.coverImageUrl}
|
||
alt={collection.title || "Collection"}
|
||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-WHITE2">
|
||
<Package size={48} className="text-GRAY" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Product Count Badge */}
|
||
{collection.productCount !== undefined && (
|
||
<div className="absolute bottom-2 left-2">
|
||
<div className="flex items-center gap-1.5 bg-white/95 backdrop-blur-sm px-2.5 py-1 rounded-full shadow-md">
|
||
<ShoppingBag size={12} className="text-black" />
|
||
<span className="text-R12 text-black font-bold">
|
||
{collection.productCount} محصول
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Collection Info */}
|
||
<div className="flex flex-col gap-1.5">
|
||
<h3 className="text-B14 font-bold text-black line-clamp-2">
|
||
{collection.title || "بدون عنوان"}
|
||
</h3>
|
||
|
||
{collection.description && (
|
||
<p className="text-R12 text-GRAY line-clamp-2">
|
||
{collection.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
|
||
{/* View All Button Card */}
|
||
{showViewAllButton && (
|
||
<Link
|
||
to="/collections"
|
||
className="flex flex-col pr-2 md:pr-4 snap-start group lg:!w-auto lg:!max-w-none lg:pr-0"
|
||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
||
>
|
||
<div className="flex flex-col items-center justify-center gap-3 p-3 bg-gradient-to-br from-black to-gray-800 rounded-2xl border border-inner-border hover:border-gray-600 transition-all duration-300 hover:shadow-lg h-full min-h-[304px]">
|
||
<div className="flex flex-col items-center justify-center gap-3">
|
||
<div className="w-16 h-16 rounded-full bg-white/10 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||
<Package size={32} className="text-white" />
|
||
</div>
|
||
<div className="flex flex-col items-center gap-2">
|
||
<h3 className="text-B16 font-bold text-white text-center">
|
||
مشاهده همه کالکشنها
|
||
</h3>
|
||
<p className="text-R12 text-white/70 text-center">
|
||
کاوش در کالکشنهای بیشتر
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 mt-2 text-white group-hover:gap-3 transition-all duration-300">
|
||
<span className="text-R14 font-bold">مشاهده همه</span>
|
||
<ChevronLeft
|
||
size={18}
|
||
className="group-hover:translate-x-[-4px] transition-transform duration-300"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Navigation Buttons Row (mobile only) */}
|
||
{collections.length > 1 && (
|
||
<div className="flex items-center justify-center gap-3 px-4 lg:hidden">
|
||
<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 collections"
|
||
>
|
||
<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 collections"
|
||
>
|
||
<ChevronLeft
|
||
size={20}
|
||
className={showLeftButton ? "text-BLACK" : "text-GRAY"}
|
||
/>
|
||
</motion.button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const HorizontalSkeleton = () => {
|
||
return (
|
||
<div className="w-full overflow-x-auto scrollbar-hide">
|
||
<div className="flex" style={{ width: "max-content" }}>
|
||
{Array(4)
|
||
.fill(1)
|
||
.map((_, index) => (
|
||
<div
|
||
key={index}
|
||
className="flex flex-col pr-2 md:pr-4 snap-start group"
|
||
style={{ width: "calc(85vw)", maxWidth: "300px" }}
|
||
>
|
||
<div className="flex flex-col gap-3 p-3 bg-WHITE2 rounded-2xl border border-inner-border">
|
||
<Skeleton className="w-full h-64 rounded-xl" />
|
||
<Skeleton className="w-3/4 h-4" />
|
||
<Skeleton className="w-full h-3" />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default HorizontalCollectionsList;
|