Connect storefront to backend catalog, search, and home slots.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2f857f1a90
commit
0074ab6629
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import type { Product } from "@/data/home";
|
||||
import ProductCard from "./ProductCard";
|
||||
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;
|
||||
};
|
||||
|
||||
const CARD_W =
|
||||
"w-[260px] shrink-0 sm:w-[280px] lg:w-[300px]";
|
||||
|
||||
export default function ProductRow({
|
||||
products,
|
||||
promo,
|
||||
className = "",
|
||||
stagger = false,
|
||||
promoSlot,
|
||||
}: 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(() => {
|
||||
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]);
|
||||
|
||||
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(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
// Never steal pointer from links/buttons — cards are full <a> links
|
||||
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) {
|
||||
// Wait for a real drag before capturing — plain clicks still hit <a>
|
||||
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]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user