345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import Image from "next/image";
|
||
import type { Product } from "@/data/home";
|
||
import BilingualTitle from "@/components/ui/BilingualTitle";
|
||
import ProductCard from "./ProductCard";
|
||
import OverlayProductCard from "./OverlayProductCard";
|
||
import PromoCard, { type PromoSlotOverride } from "./PromoCard";
|
||
|
||
type ProductRowProps = {
|
||
products: Product[];
|
||
promo: "sale" | "discount" | "autumn";
|
||
className?: string;
|
||
/** Stagger fade-in for promo + product cards when scrolled into view */
|
||
stagger?: boolean;
|
||
promoSlot?: PromoSlotOverride | null;
|
||
/**
|
||
* `carousel` — promo + horizontal product scroller (default)
|
||
* `overlay-grid` — 2×4 image-only cards; black hover overlay with name + desc
|
||
*/
|
||
layout?: "carousel" | "overlay-grid";
|
||
};
|
||
|
||
const OVERLAY_GRID_TITLES: Record<
|
||
ProductRowProps["promo"],
|
||
{ titleFa: string; titleEn: string }
|
||
> = {
|
||
sale: { titleFa: "فروش ویژه", titleEn: "Sale" },
|
||
discount: { titleFa: "پیشنهادهای ویژه", titleEn: "Special offers" },
|
||
autumn: { titleFa: "کالکشن پاییزه", titleEn: "Autumn collection" },
|
||
};
|
||
|
||
const CARD_W = "w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
|
||
|
||
export default function ProductRow({
|
||
products,
|
||
promo,
|
||
className = "",
|
||
stagger = false,
|
||
promoSlot,
|
||
layout = "carousel",
|
||
}: ProductRowProps) {
|
||
const viewportRef = useRef<HTMLDivElement>(null);
|
||
const trackRef = useRef<HTMLDivElement>(null);
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
const offsetRef = useRef(0);
|
||
const maxRef = useRef(0);
|
||
const stepRef = useRef(288);
|
||
|
||
const [offset, setOffset] = useState(0);
|
||
const [maxOffset, setMaxOffset] = useState(0);
|
||
const [visible, setVisible] = useState(!stagger);
|
||
|
||
const dragRef = useRef<{
|
||
pointerId: number | null;
|
||
startX: number;
|
||
startOffset: number;
|
||
dragging: boolean;
|
||
}>({ pointerId: null, startX: 0, startOffset: 0, dragging: false });
|
||
|
||
const applyOffset = useCallback((value: number) => {
|
||
const next = Math.min(maxRef.current, Math.max(0, value));
|
||
offsetRef.current = next;
|
||
setOffset(next);
|
||
}, []);
|
||
|
||
const measure = useCallback(() => {
|
||
const viewport = viewportRef.current;
|
||
const track = trackRef.current;
|
||
if (!viewport || !track) return;
|
||
|
||
const card = track.querySelector<HTMLElement>("[data-carousel-card]");
|
||
const styles = getComputedStyle(track);
|
||
const gap = parseFloat(styles.columnGap || styles.gap || "8") || 8;
|
||
const cardW = card?.offsetWidth ?? 280;
|
||
|
||
stepRef.current = cardW + gap;
|
||
maxRef.current = Math.max(0, track.scrollWidth - viewport.clientWidth);
|
||
setMaxOffset(maxRef.current);
|
||
applyOffset(offsetRef.current);
|
||
}, [applyOffset]);
|
||
|
||
useEffect(() => {
|
||
if (layout !== "carousel") return;
|
||
measure();
|
||
const viewport = viewportRef.current;
|
||
if (!viewport) return;
|
||
const ro = new ResizeObserver(() => measure());
|
||
ro.observe(viewport);
|
||
if (trackRef.current) ro.observe(trackRef.current);
|
||
window.addEventListener("resize", measure);
|
||
const t = window.setTimeout(measure, 200);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("resize", measure);
|
||
window.clearTimeout(t);
|
||
};
|
||
}, [measure, products, promo, layout]);
|
||
|
||
useEffect(() => {
|
||
if (!stagger) return;
|
||
const el = sectionRef.current;
|
||
if (!el) return;
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||
setVisible(true);
|
||
return;
|
||
}
|
||
const io = new IntersectionObserver(
|
||
([entry]) => {
|
||
if (entry.isIntersecting) {
|
||
setVisible(true);
|
||
io.disconnect();
|
||
}
|
||
},
|
||
{ threshold: 0.15, rootMargin: "0px 0px -6% 0px" },
|
||
);
|
||
io.observe(el);
|
||
return () => io.disconnect();
|
||
}, [stagger]);
|
||
|
||
const go = (direction: "prev" | "next") => {
|
||
const delta = direction === "next" ? stepRef.current : -stepRef.current;
|
||
applyOffset(offsetRef.current + delta);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (layout !== "carousel") return;
|
||
const viewport = viewportRef.current;
|
||
if (!viewport) return;
|
||
|
||
const onPointerDown = (e: PointerEvent) => {
|
||
if (e.button !== 0) return;
|
||
const target = e.target as HTMLElement;
|
||
if (target.closest("a, button")) return;
|
||
|
||
dragRef.current = {
|
||
pointerId: e.pointerId,
|
||
startX: e.clientX,
|
||
startOffset: offsetRef.current,
|
||
dragging: false,
|
||
};
|
||
};
|
||
|
||
const onPointerMove = (e: PointerEvent) => {
|
||
const drag = dragRef.current;
|
||
if (drag.pointerId !== e.pointerId) return;
|
||
|
||
const dx = drag.startX - e.clientX;
|
||
if (!drag.dragging) {
|
||
if (Math.abs(e.clientX - drag.startX) < 8) return;
|
||
drag.dragging = true;
|
||
viewport.setPointerCapture(e.pointerId);
|
||
if (trackRef.current) trackRef.current.style.transition = "none";
|
||
}
|
||
|
||
applyOffset(drag.startOffset + dx);
|
||
e.preventDefault();
|
||
};
|
||
|
||
const onPointerUp = (e: PointerEvent) => {
|
||
const drag = dragRef.current;
|
||
if (drag.pointerId !== e.pointerId) return;
|
||
const wasDragging = drag.dragging;
|
||
drag.pointerId = null;
|
||
drag.dragging = false;
|
||
if (trackRef.current) trackRef.current.style.transition = "";
|
||
if (wasDragging) {
|
||
try {
|
||
viewport.releasePointerCapture(e.pointerId);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
const suppress = (ev: Event) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
viewport.removeEventListener("click", suppress, true);
|
||
};
|
||
viewport.addEventListener("click", suppress, true);
|
||
window.setTimeout(
|
||
() => viewport.removeEventListener("click", suppress, true),
|
||
0,
|
||
);
|
||
}
|
||
};
|
||
|
||
viewport.addEventListener("pointerdown", onPointerDown);
|
||
viewport.addEventListener("pointermove", onPointerMove, { passive: false });
|
||
viewport.addEventListener("pointerup", onPointerUp);
|
||
viewport.addEventListener("pointercancel", onPointerUp);
|
||
|
||
return () => {
|
||
viewport.removeEventListener("pointerdown", onPointerDown);
|
||
viewport.removeEventListener("pointermove", onPointerMove);
|
||
viewport.removeEventListener("pointerup", onPointerUp);
|
||
viewport.removeEventListener("pointercancel", onPointerUp);
|
||
};
|
||
}, [applyOffset, layout]);
|
||
|
||
if (layout === "overlay-grid") {
|
||
const gridProducts = products.slice(0, 8);
|
||
const title = OVERLAY_GRID_TITLES[promo];
|
||
|
||
return (
|
||
<section
|
||
ref={sectionRef}
|
||
dir="rtl"
|
||
className={`mx-auto w-full max-w-[1600px] px-4 sm:px-6 lg:px-10 ${className}`}
|
||
>
|
||
<BilingualTitle
|
||
titleFa={promoSlot?.nameFa?.trim() || title.titleFa}
|
||
titleEn={promoSlot?.nameEn?.trim() || title.titleEn}
|
||
align="center"
|
||
size="lg"
|
||
className="mb-6 sm:mb-8"
|
||
/>
|
||
<div className="grid grid-cols-2 gap-2 sm:gap-2.5 md:grid-cols-4 lg:gap-3">
|
||
{gridProducts.map((product, i) => (
|
||
<div
|
||
key={`${promo}-overlay-${product.id}-${i}`}
|
||
className="seq-fade-item"
|
||
style={
|
||
stagger
|
||
? {
|
||
transitionDelay: visible ? `${i * 70}ms` : "0ms",
|
||
opacity: visible ? 1 : 0,
|
||
transform: visible
|
||
? "translate3d(0,0,0)"
|
||
: "translate3d(0,28px,0)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
<OverlayProductCard product={product} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section
|
||
ref={sectionRef}
|
||
dir="rtl"
|
||
className={`relative w-full pl-4 sm:pl-6 lg:pl-[120px] pr-8 sm:pr-12 lg:pr-[180px] ${className}`}
|
||
>
|
||
<div className="flex items-stretch gap-2 sm:gap-2.5 lg:gap-3">
|
||
<div
|
||
className={`${CARD_W} seq-fade-item`}
|
||
style={
|
||
stagger
|
||
? {
|
||
transitionDelay: visible ? "0ms" : "0ms",
|
||
opacity: visible ? 1 : 0,
|
||
transform: visible
|
||
? "translate3d(0,0,0)"
|
||
: "translate3d(0,28px,0)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
<PromoCard variant={promo} className="max-w-none" slot={promoSlot} />
|
||
</div>
|
||
|
||
<div
|
||
ref={viewportRef}
|
||
className="relative min-w-0 flex-1 overflow-hidden touch-pan-y"
|
||
>
|
||
<div
|
||
ref={trackRef}
|
||
className="flex cursor-grab items-stretch gap-2 transition-transform duration-300 ease-out will-change-transform active:cursor-grabbing sm:gap-2.5 lg:gap-3"
|
||
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
|
||
>
|
||
{products.map((product, i) => (
|
||
<div
|
||
key={`${promo}-${product.id}`}
|
||
data-carousel-card
|
||
className={`${CARD_W} seq-fade-item`}
|
||
style={
|
||
stagger
|
||
? {
|
||
transitionDelay: visible ? `${(i + 1) * 100}ms` : "0ms",
|
||
opacity: visible ? 1 : 0,
|
||
transform: visible
|
||
? "translate3d(0,0,0)"
|
||
: "translate3d(0,28px,0)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
<ProductCard product={product} className="max-w-none" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="pointer-events-none absolute inset-y-0 right-1 z-10 flex items-center sm:right-2">
|
||
<button
|
||
type="button"
|
||
aria-label="محصول قبلی"
|
||
disabled={offset <= 0}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
go("prev");
|
||
}}
|
||
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
|
||
>
|
||
<Image
|
||
src="/images/home/icons/caret-right.svg"
|
||
alt=""
|
||
width={20}
|
||
height={20}
|
||
unoptimized
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="pointer-events-none absolute inset-y-0 left-1 z-10 flex items-center sm:left-2">
|
||
<button
|
||
type="button"
|
||
aria-label="محصول بعدی"
|
||
disabled={offset >= maxOffset - 1}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
go("next");
|
||
}}
|
||
className="pointer-events-auto flex size-9 items-center justify-center rounded-[4px] bg-white/90 shadow-sm backdrop-blur-sm transition hover:bg-white disabled:pointer-events-none disabled:opacity-30 sm:size-10"
|
||
>
|
||
<Image
|
||
src="/images/home/icons/caret-left-dark.svg"
|
||
alt=""
|
||
width={20}
|
||
height={20}
|
||
unoptimized
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|