Connect storefront to backend catalog, search, and home slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 2f857f1a90
commit 0074ab6629
106 changed files with 5920 additions and 81 deletions
+286
View File
@@ -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>
);
}