Files
website/src/components/home/ProductRow.tsx
T

345 lines
11 KiB
TypeScript
Raw 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.
"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>
);
}